Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
use galaxyui_core::{
|
||||
integration::TestDriver,
|
||||
keymap::{CustomTag, Keystroke},
|
||||
r#async::LocalBoxFuture,
|
||||
AppContext, AssetProvider,
|
||||
};
|
||||
|
||||
pub use galaxyui_core::platform::app::*;
|
||||
|
||||
use super::AsInnerMut;
|
||||
|
||||
/// Platform-specific app implementation. On any given platform, there are at least two possible
|
||||
/// implementations:
|
||||
/// * The platform-native backend (e.g. Cocoa on macOS, or Winit+X11/Wayland on Linux)
|
||||
/// * A headless backend
|
||||
pub enum AppBackend {
|
||||
CurrentPlatform(Box<super::current::App>),
|
||||
Headless(Box<super::headless::App>),
|
||||
}
|
||||
|
||||
impl AppBackend {
|
||||
fn run(
|
||||
self,
|
||||
init_fn: impl FnOnce(&mut AppContext, LocalBoxFuture<'static, crate::App>) + 'static,
|
||||
) -> TerminationResult {
|
||||
match self {
|
||||
AppBackend::CurrentPlatform(inner) => {
|
||||
inner.run(init_fn);
|
||||
// We don't report errors for the GUI app on termination.
|
||||
Ok(())
|
||||
}
|
||||
AppBackend::Headless(inner) => inner.run(init_fn),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure to help us construct and start the application.
|
||||
pub struct AppBuilder {
|
||||
/// The actual platform-specific implementation of the app. This
|
||||
/// stores a strong reference to the application state and the
|
||||
/// callback functions to invoke when things occur at the platform
|
||||
/// level.
|
||||
inner: AppBackend,
|
||||
test_driver: Option<TestDriver>,
|
||||
custom_tag_to_keystroke_fn: Option<Box<dyn Fn(CustomTag) -> Option<Keystroke> + 'static>>,
|
||||
default_keystroke_trigger_for_custom_actions:
|
||||
Option<Box<dyn Fn(CustomTag) -> Option<Keystroke> + 'static>>,
|
||||
}
|
||||
|
||||
impl AppBuilder {
|
||||
/// Constructs a new application using the current platform backend.
|
||||
pub fn new(
|
||||
callbacks: AppCallbacks,
|
||||
assets: Box<dyn AssetProvider>,
|
||||
test_driver: Option<TestDriver>,
|
||||
) -> Self {
|
||||
let inner = super::current::App::new(callbacks, assets, test_driver.as_ref());
|
||||
|
||||
Self {
|
||||
inner: AppBackend::CurrentPlatform(Box::new(inner)),
|
||||
test_driver,
|
||||
custom_tag_to_keystroke_fn: None,
|
||||
default_keystroke_trigger_for_custom_actions: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a new application using the headless backend.
|
||||
pub fn new_headless(
|
||||
callbacks: AppCallbacks,
|
||||
assets: Box<dyn AssetProvider>,
|
||||
test_driver: Option<TestDriver>,
|
||||
) -> Self {
|
||||
let inner = super::headless::App::new(callbacks, assets, test_driver.as_ref());
|
||||
Self {
|
||||
inner: AppBackend::Headless(Box::new(inner)),
|
||||
test_driver,
|
||||
custom_tag_to_keystroke_fn: None,
|
||||
default_keystroke_trigger_for_custom_actions: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts any [`crate::keymap::Trigger::Custom`]-based binding to a traditional
|
||||
/// [`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
|
||||
/// binding with its corresponding `Keystroke` on other platforms that don't support menus.
|
||||
pub fn convert_custom_triggers_to_keystroke_triggers(
|
||||
&mut self,
|
||||
custom_tag_to_keystroke: impl Fn(CustomTag) -> Option<Keystroke> + 'static,
|
||||
) {
|
||||
self.custom_tag_to_keystroke_fn = Some(Box::new(custom_tag_to_keystroke));
|
||||
}
|
||||
|
||||
/// Registers a lookup function that returns the default keystroke for a given custom action.
|
||||
/// Used when converting custom actions to key events during keybinding editing.
|
||||
pub fn register_default_keystroke_triggers_for_custom_actions(
|
||||
&mut self,
|
||||
custom_tag_to_keystroke: impl Fn(CustomTag) -> Option<Keystroke> + 'static,
|
||||
) {
|
||||
self.default_keystroke_trigger_for_custom_actions = Some(Box::new(custom_tag_to_keystroke));
|
||||
}
|
||||
|
||||
/// Runs the application, invoking the provided function to
|
||||
/// initialize application state and be ready to process
|
||||
/// events from the main event loop.
|
||||
pub fn run(mut self, init_fn: impl FnOnce(&mut AppContext) + 'static) -> TerminationResult {
|
||||
let custom_tag_to_keystroke = self.custom_tag_to_keystroke_fn.take();
|
||||
// Wrap the initialization fn with one that first tries to convert custom triggers to
|
||||
// keystroke triggers.
|
||||
let init_fn = |ctx: &mut AppContext| {
|
||||
if let Some(custom_tag_to_keystroke) = custom_tag_to_keystroke {
|
||||
ctx.convert_custom_triggers_to_keystroke_triggers(custom_tag_to_keystroke);
|
||||
}
|
||||
if let Some(default_keystroke_trigger_for_custom_actions) =
|
||||
self.default_keystroke_trigger_for_custom_actions
|
||||
{
|
||||
ctx.register_default_keystroke_triggers_for_custom_actions(
|
||||
default_keystroke_trigger_for_custom_actions,
|
||||
);
|
||||
}
|
||||
init_fn(ctx);
|
||||
};
|
||||
|
||||
if let Some(test_driver) = self.test_driver {
|
||||
self.inner.run(move |ctx, ui_app_future| {
|
||||
init_fn(ctx);
|
||||
|
||||
ctx.foreground_executor()
|
||||
.spawn(async move {
|
||||
let ui_app = ui_app_future.await;
|
||||
test_driver.run_test_and_cleanup(ui_app).await;
|
||||
})
|
||||
.detach();
|
||||
})
|
||||
} else {
|
||||
self.inner.run(|ctx, _| init_fn(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsInnerMut<AppBackend> for AppBuilder {
|
||||
fn as_inner_mut(&mut self) -> &mut AppBackend {
|
||||
&mut self.inner
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use futures::future::LocalBoxFuture;
|
||||
|
||||
use crate::platform::app::TerminationResult;
|
||||
use crate::platform::test::FontDB as TestFontDB;
|
||||
use crate::{
|
||||
integration::TestDriver,
|
||||
platform::{self},
|
||||
AppContext, AssetProvider,
|
||||
};
|
||||
|
||||
use super::delegate::{self, AppDelegate};
|
||||
use super::event_loop::{self, AppEvent};
|
||||
use super::windowing::WindowManager;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub struct App {
|
||||
callbacks: platform::app::AppCallbacks,
|
||||
assets: Box<dyn AssetProvider>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(in crate::platform) fn new(
|
||||
callbacks: platform::app::AppCallbacks,
|
||||
assets: Box<dyn AssetProvider>,
|
||||
test_driver: Option<&TestDriver>,
|
||||
) -> Self {
|
||||
// Other platforms use the test_driver parameter to enable an alternative platform delegate implementation
|
||||
// in integration tests - that doesn't apply here.
|
||||
let _ = test_driver;
|
||||
Self { callbacks, assets }
|
||||
}
|
||||
|
||||
pub(in crate::platform) fn run(
|
||||
self,
|
||||
init_fn: impl FnOnce(&mut AppContext, LocalBoxFuture<'static, crate::App>) + 'static,
|
||||
) -> TerminationResult {
|
||||
let App { callbacks, assets } = self;
|
||||
|
||||
let (sender, receiver) = mpsc::channel::<AppEvent>();
|
||||
|
||||
// Mark this thread as the main thread for DispatchDelegate checks.
|
||||
delegate::mark_current_thread_as_main();
|
||||
|
||||
let platform_delegate = Box::new(AppDelegate::new(sender.clone()));
|
||||
let window_manager = Box::new(WindowManager::new(sender.clone()));
|
||||
// Reuse the testing FontDB implementation, as no font features are needed in headless mode.
|
||||
let font_db: Box<dyn platform::FontDB> = Box::new(TestFontDB::new());
|
||||
|
||||
let ui_app = crate::App::new(platform_delegate, window_manager, font_db, assets)
|
||||
.expect("should not fail to construct application");
|
||||
|
||||
let mut callbacks =
|
||||
galaxyui_core::platform::app::AppCallbackDispatcher::new(callbacks, ui_app.clone());
|
||||
|
||||
// Run the event loop until the app terminates.
|
||||
event_loop::run(ui_app, &mut callbacks, Box::new(init_fn), receiver, sender)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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::thread;
|
||||
|
||||
use super::event_loop::AppEvent;
|
||||
|
||||
/// 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.
|
||||
static MAIN_THREAD_ID: OnceLock<thread::ThreadId> = OnceLock::new();
|
||||
|
||||
/// Marks the current thread as the application's main thread.
|
||||
///
|
||||
/// Panics if called more than once.
|
||||
pub(super) fn mark_current_thread_as_main() {
|
||||
MAIN_THREAD_ID
|
||||
.set(thread::current().id())
|
||||
.expect("should only call mark_current_thread_as_main once!");
|
||||
}
|
||||
|
||||
pub struct AppDelegate {
|
||||
clipboard: InMemoryClipboard,
|
||||
cursor_shape: Mutex<Cursor>,
|
||||
event_sender: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl AppDelegate {
|
||||
pub(super) fn new(event_sender: Sender<AppEvent>) -> Self {
|
||||
Self {
|
||||
clipboard: InMemoryClipboard::default(),
|
||||
cursor_shape: Mutex::new(Cursor::Arrow),
|
||||
event_sender,
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl platform::Delegate for AppDelegate {
|
||||
fn dispatch_delegate(&self) -> Arc<dyn platform::DispatchDelegate> {
|
||||
Arc::new(DispatchDelegate {
|
||||
event_sender: self.event_sender.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_user_attention(&self, _window_id: crate::WindowId) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn clipboard(&mut self) -> &mut dyn crate::Clipboard {
|
||||
&mut self.clipboard
|
||||
}
|
||||
|
||||
fn system_theme(&self) -> platform::SystemTheme {
|
||||
platform::SystemTheme::Light
|
||||
}
|
||||
|
||||
fn open_url(&self, url: &str) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Use macOS platform implementation
|
||||
crate::platform::mac::Window::open_url(url);
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// Reuse the winit implementation for non-mac platforms
|
||||
crate::windowing::winit::delegate::open_url_in_system(url);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file_path(&self, _path: &std::path::Path) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn open_file_path_in_explorer(&self, _path: &std::path::Path) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn open_file_picker(
|
||||
&self,
|
||||
callback: platform::FilePickerCallback,
|
||||
_file_picker_config: platform::FilePickerConfiguration,
|
||||
) {
|
||||
self.send_event(AppEvent::RunCallback(Box::new(move |ctx| {
|
||||
callback(Ok(vec![]), ctx);
|
||||
})));
|
||||
}
|
||||
|
||||
fn open_save_file_picker(
|
||||
&self,
|
||||
callback: platform::SaveFilePickerCallback,
|
||||
_config: platform::SaveFilePickerConfiguration,
|
||||
) {
|
||||
self.send_event(AppEvent::RunCallback(Box::new(move |ctx| {
|
||||
callback(None, ctx);
|
||||
})));
|
||||
}
|
||||
|
||||
fn application_bundle_info(
|
||||
&self,
|
||||
_bundle_identifier: &str,
|
||||
) -> Option<crate::ApplicationBundleInfo<'_>> {
|
||||
// This is unsupported, though we could delegate to the macOS implementation.
|
||||
None
|
||||
}
|
||||
|
||||
fn show_native_platform_modal(
|
||||
&self,
|
||||
_id: crate::modals::ModalId,
|
||||
_modal: crate::modals::AlertDialog,
|
||||
) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn request_desktop_notification_permissions(
|
||||
&self,
|
||||
on_completion: platform::RequestNotificationPermissionsCallback,
|
||||
) {
|
||||
self.send_event(AppEvent::RunCallback(Box::new(move |ctx| {
|
||||
on_completion(RequestPermissionsOutcome::PermissionsDenied, ctx);
|
||||
})));
|
||||
}
|
||||
|
||||
fn send_desktop_notification(
|
||||
&self,
|
||||
_notification_content: crate::notification::UserNotification,
|
||||
_window_id: crate::WindowId,
|
||||
on_error: platform::SendNotificationErrorCallback,
|
||||
) {
|
||||
self.send_event(AppEvent::RunCallback(Box::new(move |ctx| {
|
||||
on_error(NotificationSendError::PermissionsDenied, ctx);
|
||||
})));
|
||||
}
|
||||
|
||||
fn set_cursor_shape(&self, cursor: Cursor) {
|
||||
*self.cursor_shape.lock() = cursor;
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
fn get_cursor_shape(&self) -> Cursor {
|
||||
*self.cursor_shape.lock()
|
||||
}
|
||||
|
||||
fn close_ime_async(&self, _window_id: crate::WindowId) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn is_ime_open(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn open_character_palette(&self) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn set_accessibility_contents(&self, _content: crate::accessibility::AccessibilityContent) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn register_global_shortcut(&self, _shortcut: crate::keymap::Keystroke) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn unregister_global_shortcut(&self, _shortcut: &crate::keymap::Keystroke) {
|
||||
// Unsupported.
|
||||
}
|
||||
|
||||
fn terminate_app(&self, termination_mode: platform::TerminationMode) {
|
||||
self.send_event(AppEvent::Terminate(termination_mode));
|
||||
}
|
||||
|
||||
fn is_screen_reader_enabled(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn microphone_access_state(&self) -> platform::MicrophoneAccessState {
|
||||
platform::MicrophoneAccessState::Denied
|
||||
}
|
||||
|
||||
fn is_headless(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct DispatchDelegate {
|
||||
event_sender: Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl platform::DispatchDelegate for DispatchDelegate {
|
||||
fn is_main_thread(&self) -> bool {
|
||||
thread::current().id()
|
||||
== *MAIN_THREAD_ID
|
||||
.get()
|
||||
.expect("should have marked a thread as the main thread")
|
||||
}
|
||||
|
||||
fn run_on_main_thread(&self, task: async_task::Runnable) {
|
||||
// See crate::windowing::winit::delegate::DispatchDelegate for why we use ManuallyDrop.
|
||||
if self
|
||||
.event_sender
|
||||
.send(AppEvent::RunTask(ManuallyDrop::new(task)))
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Tried to send event, but event loop is no longer running");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::mem::ManuallyDrop;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::{
|
||||
platform::{
|
||||
self,
|
||||
app::{AppCallbackDispatcher, ApproveTerminateResult, TerminationResult},
|
||||
TerminationMode,
|
||||
},
|
||||
AppContext, WindowId,
|
||||
};
|
||||
|
||||
/// Application events handled on the headless platform's main thread.
|
||||
pub(super) enum AppEvent {
|
||||
/// Run the wrapped task on the main thread.
|
||||
RunTask(ManuallyDrop<async_task::Runnable>),
|
||||
/// Run a synchronous callback on the main thread.
|
||||
RunCallback(Box<dyn FnOnce(&mut AppContext) + Send + Sync>),
|
||||
/// Close a window.
|
||||
CloseWindow(WindowId),
|
||||
/// Active window changed.
|
||||
ActiveWindowChanged(Option<WindowId>),
|
||||
/// Exit the event loop, terminating the application.
|
||||
Terminate(TerminationMode),
|
||||
}
|
||||
|
||||
/// Run a simple, blocking event loop that processes AppEvent messages until termination.
|
||||
pub(super) fn run(
|
||||
mut ui_app: crate::App,
|
||||
callbacks: &mut AppCallbackDispatcher,
|
||||
init_fn: platform::app::AppInitCallbackFn,
|
||||
receiver: Receiver<AppEvent>,
|
||||
sender: Sender<AppEvent>,
|
||||
) -> TerminationResult {
|
||||
// Set up Ctrl-C handler to gracefully terminate the app
|
||||
setup_signal_handler(sender);
|
||||
|
||||
// First, initialize the app.
|
||||
callbacks.initialize_app(init_fn);
|
||||
|
||||
// Then, process events until termination.
|
||||
for event in receiver.iter() {
|
||||
match event {
|
||||
AppEvent::RunCallback(callback) => ui_app.update(callback),
|
||||
AppEvent::RunTask(task) => {
|
||||
// Poll a task on the main thread.
|
||||
let task = ManuallyDrop::into_inner(task);
|
||||
task.run();
|
||||
}
|
||||
AppEvent::Terminate(termination_mode) => {
|
||||
let should_terminate = match termination_mode {
|
||||
TerminationMode::Cancellable => {
|
||||
matches!(
|
||||
callbacks.should_terminate_app(),
|
||||
ApproveTerminateResult::Terminate
|
||||
)
|
||||
}
|
||||
TerminationMode::ForceTerminate | TerminationMode::ContentTransferred => true,
|
||||
};
|
||||
if should_terminate {
|
||||
break;
|
||||
}
|
||||
}
|
||||
AppEvent::CloseWindow(window_id) => {
|
||||
// Notify the app that a window is closing. The app will then remove the window
|
||||
// from WindowManager.
|
||||
callbacks.window_will_close(window_id);
|
||||
}
|
||||
AppEvent::ActiveWindowChanged(window_id) => {
|
||||
callbacks.active_window_changed(window_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the receiver so the Ctrl+C signal handler's channel send will fail,
|
||||
// causing it to fall through to `process::exit(130)`. Without this, the
|
||||
// send succeeds (since the receiver is still in scope) but nobody is reading
|
||||
// from the channel, making Ctrl+C ineffective during shutdown.
|
||||
drop(receiver);
|
||||
|
||||
callbacks.app_will_terminate();
|
||||
|
||||
ui_app.termination_result().unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
/// Set up a signal handler for Ctrl-C (SIGINT) to gracefully terminate the app.
|
||||
///
|
||||
/// When Ctrl-C is received, this will send a Terminate event to the event loop,
|
||||
/// allowing the app to shut down gracefully via the existing termination logic.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn setup_signal_handler(sender: Sender<AppEvent>) {
|
||||
let result = ctrlc::set_handler(move || {
|
||||
log::info!("Received Ctrl-C signal in headless mode, terminating application");
|
||||
// Send a ForceTerminate event to ensure the app exits cleanly.
|
||||
// We use ForceTerminate rather than Cancellable to ensure the app exits
|
||||
// even if there are unsaved changes or other conditions that might prevent shutdown.
|
||||
if sender
|
||||
.send(AppEvent::Terminate(TerminationMode::ForceTerminate))
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Failed to send termination event - event loop may have already stopped");
|
||||
// If we can't send the event, force exit
|
||||
std::process::exit(130); // 128 + SIGINT (2) = 130
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = result {
|
||||
log::warn!("Failed to set up Ctrl-C handler: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
fn setup_signal_handler(_sender: Sender<AppEvent>) {
|
||||
// No signal handling on WASM
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! A headless implementation of the UI framework's platform abstraction.
|
||||
//!
|
||||
//! This provides enough functionality to run an app, but no GUI or visible output.
|
||||
|
||||
mod app;
|
||||
mod delegate;
|
||||
mod event_loop;
|
||||
mod windowing;
|
||||
|
||||
pub use app::App;
|
||||
pub use delegate::AppDelegate;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use windowing::Window;
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::{cell::RefCell, collections::HashMap, rc::Rc, 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;
|
||||
|
||||
pub struct WindowManager {
|
||||
windows: HashMap<WindowId, Rc<Window>>,
|
||||
active_window: RefCell<Option<WindowId>>,
|
||||
event_sender: mpsc::Sender<AppEvent>,
|
||||
}
|
||||
|
||||
impl WindowManager {
|
||||
pub(super) fn new(event_sender: mpsc::Sender<AppEvent>) -> Self {
|
||||
Self {
|
||||
windows: HashMap::new(),
|
||||
active_window: RefCell::new(None),
|
||||
event_sender,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_active_window(&self, window_id: Option<WindowId>) {
|
||||
*self.active_window.borrow_mut() = window_id;
|
||||
|
||||
if self
|
||||
.event_sender
|
||||
.send(AppEvent::ActiveWindowChanged(window_id))
|
||||
.is_err()
|
||||
{
|
||||
log::warn!(
|
||||
"Tried to send ActiveWindowChanged event, but event loop is no longer running"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl galaxyui_core::platform::WindowManager for WindowManager {
|
||||
fn open_window(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
window_options: WindowOptions,
|
||||
callbacks: WindowCallbacks,
|
||||
) -> Result<()> {
|
||||
let window = Rc::new(Window::new(window_options, callbacks));
|
||||
self.windows.insert(window_id, window);
|
||||
self.set_active_window(Some(window_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn platform_window(&self, window_id: WindowId) -> galaxyui_core::OptionalPlatformWindow {
|
||||
self.windows
|
||||
.get(&window_id)
|
||||
.map(Rc::clone)
|
||||
.map(|inner| inner as Rc<dyn crate::platform::Window>)
|
||||
}
|
||||
|
||||
fn remove_window(&mut self, window_id: WindowId) {
|
||||
self.windows.remove(&window_id);
|
||||
if *self.active_window.borrow() == Some(window_id) {
|
||||
self.set_active_window(None);
|
||||
}
|
||||
}
|
||||
|
||||
fn active_window_id(&self) -> Option<WindowId> {
|
||||
*self.active_window.borrow()
|
||||
}
|
||||
|
||||
fn key_window_is_modal_panel(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn app_is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn activate_app(&self, last_active_window: Option<WindowId>) -> Option<WindowId> {
|
||||
self.set_active_window(last_active_window);
|
||||
last_active_window
|
||||
}
|
||||
|
||||
fn show_window_and_focus_app(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
_behavior: platform::WindowFocusBehavior,
|
||||
) {
|
||||
self.set_active_window(Some(window_id));
|
||||
}
|
||||
|
||||
fn hide_app(&self) {
|
||||
// No-op.
|
||||
}
|
||||
|
||||
fn hide_window(&self, window_id: WindowId) {
|
||||
// If hiding the active window, clear focus.
|
||||
if *self.active_window.borrow() == Some(window_id) {
|
||||
self.set_active_window(None);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_window_bounds(&self, window_id: WindowId, bound: RectF) {
|
||||
if let Some(window) = self.windows.get(&window_id) {
|
||||
window.set_bounds(bound);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_all_windows_background_blur_radius(&self, _blur_radius_pixels: u8) {
|
||||
// No-op for headless.
|
||||
}
|
||||
|
||||
fn set_all_windows_background_blur_texture(&self, _use_blur_texture: bool) {
|
||||
// No-op for headless.
|
||||
}
|
||||
|
||||
fn set_window_title(&self, _window_id: WindowId, _title: &str) {
|
||||
// No-op for headless.
|
||||
}
|
||||
|
||||
fn close_window_async(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
_termination_mode: platform::TerminationMode,
|
||||
) {
|
||||
// In headless mode, always force-close the window since there's no confirmation dialog.
|
||||
if self
|
||||
.event_sender
|
||||
.send(AppEvent::CloseWindow(window_id))
|
||||
.is_err()
|
||||
{
|
||||
log::warn!("Tried to send event, but event loop is no longer running");
|
||||
}
|
||||
}
|
||||
|
||||
fn active_display_bounds(&self) -> RectF {
|
||||
// A single default display.
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn active_display_id(&self) -> crate::DisplayId {
|
||||
crate::DisplayId::from(0)
|
||||
}
|
||||
|
||||
fn display_count(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn bounds_for_display_idx(&self, _idx: crate::DisplayIdx) -> Option<RectF> {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn active_cursor_position_updated(&self) {
|
||||
// No-op.
|
||||
}
|
||||
|
||||
fn windowing_system(&self) -> Option<crate::windowing::System> {
|
||||
None
|
||||
}
|
||||
|
||||
fn os_window_manager_name(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_tiling_window_manager(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Window {
|
||||
callbacks: WindowCallbacks,
|
||||
bounds: RefCell<RectF>,
|
||||
fullscreen_state: RefCell<platform::FullscreenState>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
fn new(options: WindowOptions, callbacks: WindowCallbacks) -> Self {
|
||||
let bounds = match options.bounds {
|
||||
platform::WindowBounds::Default => RectF::new(vec2f(0.0, 0.0), vec2f(1024.0, 768.0)),
|
||||
platform::WindowBounds::ExactSize(size) => RectF::new(vec2f(0.0, 0.0), size),
|
||||
platform::WindowBounds::ExactPosition(rect) => rect,
|
||||
};
|
||||
Self {
|
||||
callbacks,
|
||||
bounds: RefCell::new(bounds),
|
||||
fullscreen_state: RefCell::new(options.fullscreen_state),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_bounds(&self, rect: RectF) {
|
||||
*self.bounds.borrow_mut() = rect;
|
||||
}
|
||||
}
|
||||
|
||||
impl platform::Window for Window {
|
||||
fn minimize(&self) {}
|
||||
|
||||
fn toggle_maximized(&self) {}
|
||||
|
||||
fn toggle_fullscreen(&self) {}
|
||||
|
||||
fn fullscreen_state(&self) -> platform::FullscreenState {
|
||||
*self.fullscreen_state.borrow()
|
||||
}
|
||||
|
||||
fn set_titlebar_height(&self, _height: f64) {}
|
||||
|
||||
fn supports_transparency(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn graphics_backend(&self) -> platform::GraphicsBackend {
|
||||
platform::GraphicsBackend::Empty
|
||||
}
|
||||
|
||||
fn supported_backends(&self) -> Vec<platform::GraphicsBackend> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn uses_native_window_decorations(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn as_ctx(&self) -> &dyn platform::WindowContext {
|
||||
self
|
||||
}
|
||||
|
||||
fn callbacks(&self) -> &crate::windowing::WindowCallbacks {
|
||||
&self.callbacks
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl platform::WindowContext for Window {
|
||||
fn size(&self) -> Vector2F {
|
||||
self.bounds.borrow().size()
|
||||
}
|
||||
|
||||
fn origin(&self) -> Vector2F {
|
||||
self.bounds.borrow().origin()
|
||||
}
|
||||
|
||||
fn backing_scale_factor(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn max_texture_dimension_2d(&self) -> Option<u32> {
|
||||
Some(2048)
|
||||
}
|
||||
|
||||
fn render_scene(&self, _scene: Rc<crate::Scene>) {}
|
||||
|
||||
fn request_redraw(&self) {}
|
||||
|
||||
fn request_frame_capture(
|
||||
&self,
|
||||
_callback: Box<dyn FnOnce(platform::CapturedFrame) + Send + 'static>,
|
||||
) {
|
||||
// no-op for headless
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Re-export a couple winit types and modules as the concrete implementations
|
||||
// for the linux platform.
|
||||
pub use crate::windowing::winit::app::App;
|
||||
|
||||
use crate::{
|
||||
windowing::{self, WindowingSystem},
|
||||
AppContext,
|
||||
};
|
||||
|
||||
use super::{app::AppBackend, AsInnerMut};
|
||||
|
||||
/// An extension trait defining additional configurability for
|
||||
/// applications when running on Linux.
|
||||
pub trait AppBuilderExt {
|
||||
/// Sets the value to use for WM_CLASS (when running under X11) or app_id
|
||||
/// (when running under Wayland).
|
||||
///
|
||||
/// This is used to identify the application and link it properly to its
|
||||
/// .desktop file and associated resources (like app icons).
|
||||
fn set_window_class(&mut self, window_class: String);
|
||||
|
||||
/// Whether or not to force the use of XWayland for users running Wayland.
|
||||
fn force_x11(&mut self, force_x11: bool);
|
||||
}
|
||||
|
||||
impl AppBuilderExt for super::AppBuilder {
|
||||
fn set_window_class(&mut self, window_class: String) {
|
||||
match self.as_inner_mut() {
|
||||
AppBackend::CurrentPlatform(app) => app.set_window_class(window_class),
|
||||
AppBackend::Headless(_) => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn force_x11(&mut self, force_x11: bool) {
|
||||
match self.as_inner_mut() {
|
||||
AppBackend::CurrentPlatform(app) => app.force_x11(force_x11),
|
||||
AppBackend::Headless(_) => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the windowing system that the user is running before the event loop is created.
|
||||
pub fn user_windowing_system() -> WindowingSystem {
|
||||
// This mirrors winit's logic [here](https://github.com/rust-windowing/winit/blob/4cd6877e8e19e7e1ba957a409394dca1af4afcdd/src/platform_impl/linux/mod.rs#L735-L745).
|
||||
if std::env::var("WAYLAND_DISPLAY")
|
||||
.ok()
|
||||
.filter(|var| !var.is_empty())
|
||||
.or_else(|| std::env::var("WAYLAND_SOCKET").ok())
|
||||
.filter(|var| !var.is_empty())
|
||||
.is_some()
|
||||
{
|
||||
WindowingSystem::Wayland
|
||||
} else {
|
||||
WindowingSystem::X11
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
pub fn is_wayland_env_var_set() -> bool {
|
||||
std::env::var_os("WARP_ENABLE_WAYLAND")
|
||||
.is_some_and(|warp_enable_wayland| warp_enable_wayland.eq_ignore_ascii_case("1"))
|
||||
}
|
||||
|
||||
pub fn windowing_system_is_customizable(app: &AppContext) -> bool {
|
||||
!is_wayland_env_var_set()
|
||||
&& app
|
||||
.windows()
|
||||
.windowing_system()
|
||||
.is_some_and(|windowing_system| {
|
||||
matches!(
|
||||
windowing_system,
|
||||
windowing::System::X11 { is_x_wayland: true } | windowing::System::Wayland
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
use cocoa::appkit::NSApp;
|
||||
use cocoa::foundation::{NSUInteger, NSURL};
|
||||
use cocoa::{
|
||||
base::{id, nil},
|
||||
foundation::{NSArray, NSAutoreleasePool, NSData, NSString},
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
use objc::{
|
||||
class, msg_send,
|
||||
runtime::{Object, Sel, BOOL, NO, YES},
|
||||
sel, sel_impl,
|
||||
};
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
ffi::CStr,
|
||||
os::raw::{c_char, c_void},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const RUST_WRAPPER_IVAR_NAME: &str = "rustWrapper";
|
||||
|
||||
extern "C" {
|
||||
// Implemented in ObjC to get the warp NSApplication subclass.
|
||||
pub(super) fn get_warp_app() -> id;
|
||||
}
|
||||
|
||||
/// An extension trait defining additional configurability for
|
||||
/// applications when running on macOS.
|
||||
pub trait AppExt {
|
||||
/// Sets whether or not the application should be activated
|
||||
/// when it is launched.
|
||||
fn set_activate_on_launch(&mut self, value: bool);
|
||||
|
||||
/// Sets the application icon which should be used when running
|
||||
/// without an application bundle.
|
||||
fn set_dev_icon(&mut self, value: Cow<'static, [u8]>);
|
||||
|
||||
/// Sets the main menu bar constructor function.
|
||||
fn set_menu_bar_builder(&mut self, value: impl FnOnce(&mut AppContext) -> MenuBar + 'static);
|
||||
|
||||
/// Sets the macOS dock menu constructor function.
|
||||
fn set_dock_menu_builder(&mut self, value: impl FnOnce(&mut AppContext) -> Menu + 'static);
|
||||
}
|
||||
|
||||
type MenuBarBuilderFn = Box<dyn FnOnce(&mut AppContext) -> MenuBar>;
|
||||
type DockMenuBuilderFn = Box<dyn FnOnce(&mut AppContext) -> Menu>;
|
||||
|
||||
/// The actual application, from the perspective of the platform and the
|
||||
/// main event loop. This is the true owner of all application state.
|
||||
pub struct App {
|
||||
callbacks: AppCallbackDispatcher,
|
||||
activate_on_launch: bool,
|
||||
dev_icon: Option<Cow<'static, [u8]>>,
|
||||
menu_bar_builder: Option<MenuBarBuilderFn>,
|
||||
dock_menu_builder: Option<DockMenuBuilderFn>,
|
||||
init_fn: Option<platform::app::AppInitCallbackFn>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(in crate::platform) fn new(
|
||||
callbacks: platform::app::AppCallbacks,
|
||||
assets: Box<dyn AssetProvider>,
|
||||
test_driver: Option<&TestDriver>,
|
||||
) -> Self {
|
||||
let platform_delegate: Box<dyn platform::Delegate> = if test_driver.is_some() {
|
||||
Box::new(
|
||||
super::delegate::IntegrationTestDelegate::new()
|
||||
.expect("should not fail to create platform delegate"),
|
||||
)
|
||||
} else {
|
||||
Box::new(
|
||||
super::delegate::AppDelegate::new()
|
||||
.expect("should not fail to create platform delegate"),
|
||||
)
|
||||
};
|
||||
|
||||
let window_manager: Box<dyn platform::WindowManager> = if test_driver.is_some() {
|
||||
Box::new(IntegrationTestWindowManager::new())
|
||||
} else {
|
||||
Box::new(WindowManager::new())
|
||||
};
|
||||
|
||||
let ui_app = crate::App::new(
|
||||
platform_delegate,
|
||||
window_manager,
|
||||
Box::new(super::fonts::FontDB::new()),
|
||||
assets,
|
||||
)
|
||||
.expect("should not fail to construct application");
|
||||
|
||||
Self {
|
||||
callbacks: AppCallbackDispatcher::new(callbacks, ui_app),
|
||||
activate_on_launch: true,
|
||||
dev_icon: None,
|
||||
menu_bar_builder: None,
|
||||
dock_menu_builder: None,
|
||||
init_fn: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::platform) fn run(
|
||||
mut self,
|
||||
init_fn: impl FnOnce(&mut AppContext, LocalBoxFuture<'static, crate::App>) + 'static,
|
||||
) {
|
||||
self.init_fn = Some(Box::new(init_fn));
|
||||
|
||||
unsafe {
|
||||
let pool = NSAutoreleasePool::new(nil);
|
||||
|
||||
// Get (and create, if necessary) the underlying NSApplication.
|
||||
let app: id = get_warp_app();
|
||||
|
||||
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)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let app_delegate: id = msg_send![app, 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);
|
||||
|
||||
if let Some(dev_icon) = dev_icon {
|
||||
let _: () = msg_send![app, setApplicationIconImage: dev_icon];
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppExt for AppBuilder {
|
||||
fn set_activate_on_launch(&mut self, value: bool) {
|
||||
match self.as_inner_mut() {
|
||||
AppBackend::CurrentPlatform(app) => app.activate_on_launch = value,
|
||||
AppBackend::Headless(_) => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_dev_icon(&mut self, value: Cow<'static, [u8]>) {
|
||||
match self.as_inner_mut() {
|
||||
AppBackend::CurrentPlatform(app) => app.dev_icon = Some(value),
|
||||
AppBackend::Headless(_) => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_menu_bar_builder(&mut self, value: impl FnOnce(&mut AppContext) -> MenuBar + 'static) {
|
||||
match self.as_inner_mut() {
|
||||
AppBackend::CurrentPlatform(app) => app.menu_bar_builder = Some(Box::new(value)),
|
||||
AppBackend::Headless(_) => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_dock_menu_builder(&mut self, value: impl FnOnce(&mut AppContext) -> Menu + 'static) {
|
||||
match self.as_inner_mut() {
|
||||
AppBackend::CurrentPlatform(app) => app.dock_menu_builder = Some(Box::new(value)),
|
||||
AppBackend::Headless(_) => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn get_app(object: &mut Object) -> &mut App {
|
||||
let wrapper_ptr: *mut c_void = *object.get_ivar(RUST_WRAPPER_IVAR_NAME);
|
||||
&mut *(wrapper_ptr as *mut App)
|
||||
}
|
||||
|
||||
pub(super) fn callback_dispatcher() -> &'static mut AppCallbackDispatcher {
|
||||
unsafe {
|
||||
let app = get_warp_app();
|
||||
let app = get_app(&mut *app);
|
||||
&mut app.callbacks
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_send_global_keybinding(
|
||||
this: &mut Object,
|
||||
modifiers: NSUInteger,
|
||||
key_code: NSUInteger,
|
||||
) {
|
||||
let keystroke = {
|
||||
let modifiers = modifiers as u16;
|
||||
let shift_key_pressed = (modifiers & SHIFT_KEY) > 0;
|
||||
Keycode(key_code as u16)
|
||||
.try_to_key_name(shift_key_pressed)
|
||||
.map(|key| Keystroke {
|
||||
ctrl: (modifiers & CONTROL_KEY) > 0,
|
||||
alt: (modifiers & OPTION_KEY) > 0,
|
||||
shift: shift_key_pressed,
|
||||
cmd: (modifiers & CMD_KEY) > 0,
|
||||
meta: false,
|
||||
key,
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(keystroke) = keystroke {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.global_shortcut_triggered(keystroke);
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C-unwind" fn warp_app_will_finish_launching(this: &mut Object) {
|
||||
log::info!("application will finish launching");
|
||||
|
||||
let app = get_app(this);
|
||||
|
||||
if app.activate_on_launch {
|
||||
let _: () = msg_send![NSApp(), activateIgnoringOtherApps: YES];
|
||||
}
|
||||
|
||||
if let Some(init_fn) = app.init_fn.take() {
|
||||
app.callbacks.initialize_app(init_fn);
|
||||
}
|
||||
|
||||
let app_delegate: id = msg_send![NSApp(), delegate];
|
||||
|
||||
if app.callbacks.has_internet_reachability_changed_callback() {
|
||||
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];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_did_become_active(this: &mut Object, _: Sel, _: id) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.app_became_active();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_internet_reachability_changed(
|
||||
this: &mut Object,
|
||||
can_reach: u8,
|
||||
) {
|
||||
let is_reachable = can_reach != 0;
|
||||
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.internet_reachability_changed(is_reachable);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let app = unsafe { get_app(this) };
|
||||
|
||||
match app.callbacks.should_terminate_app() {
|
||||
ApproveTerminateResult::Terminate => YES,
|
||||
ApproveTerminateResult::Cancel => NO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a NSAlert object if we want to show a dialog for users to confirm or
|
||||
/// nil for closing the window immediately.
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_should_close_window(
|
||||
this: &mut Object,
|
||||
window_id: &mut Object,
|
||||
) -> BOOL {
|
||||
let app = unsafe { get_app(this) };
|
||||
let window = unsafe { get_window_state(window_id) };
|
||||
|
||||
match app.callbacks.should_close_window(window.id()) {
|
||||
ApproveTerminateResult::Terminate => YES,
|
||||
ApproveTerminateResult::Cancel => NO,
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_are_key_bindings_disabled_for_window(
|
||||
this: &mut Object,
|
||||
window_id: &mut Object,
|
||||
) -> BOOL {
|
||||
let app = unsafe { get_app(this) };
|
||||
let window = unsafe { get_window_state(window_id) };
|
||||
|
||||
let disabled = app
|
||||
.callbacks
|
||||
.with_mutable_app_context(|ctx| !ctx.key_bindings_enabled(window.id()));
|
||||
|
||||
if disabled {
|
||||
YES
|
||||
} else {
|
||||
NO
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_has_binding_for_keystroke(
|
||||
this: &mut Object,
|
||||
event: id,
|
||||
) -> BOOL {
|
||||
let app = unsafe { get_app(this) };
|
||||
let warp_event = unsafe { super::event::from_native(event, None, false) };
|
||||
|
||||
let Some(Event::KeyDown { keystroke, .. }) = warp_event else {
|
||||
return NO;
|
||||
};
|
||||
let has_binding = app.callbacks.with_mutable_app_context(|ctx| {
|
||||
ctx.get_key_bindings().any(|binding| {
|
||||
if let Trigger::Keystrokes(keystrokes) = binding.trigger {
|
||||
keystrokes.len() == 1 && keystrokes[0] == keystroke
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if has_binding {
|
||||
YES
|
||||
} else {
|
||||
NO
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_has_custom_action_for_keystroke(
|
||||
this: &mut Object,
|
||||
event: id,
|
||||
) -> BOOL {
|
||||
let app = unsafe { get_app(this) };
|
||||
let warp_event = unsafe { super::event::from_native(event, None, false) };
|
||||
|
||||
let Some(Event::KeyDown { keystroke, .. }) = warp_event else {
|
||||
return NO;
|
||||
};
|
||||
let has_binding = app.callbacks.with_mutable_app_context(|ctx| {
|
||||
ctx.custom_action_bindings()
|
||||
.any(|binding| match binding.trigger {
|
||||
Trigger::Keystrokes(keystrokes) => {
|
||||
keystrokes.len() == 1 && keystrokes[0] == keystroke
|
||||
}
|
||||
Trigger::Custom(tag) => ctx
|
||||
.default_keystroke_trigger_for_custom_action(*tag)
|
||||
.is_some_and(|k| k == keystroke),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
|
||||
if has_binding {
|
||||
YES
|
||||
} else {
|
||||
NO
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_disable_warning_modal(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.warning_modal_disabled();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_process_modal_response(
|
||||
this: &mut Object,
|
||||
modal_id: ModalId,
|
||||
response: usize,
|
||||
disable_modal: bool,
|
||||
) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks
|
||||
.process_platform_modal_response(modal_id, response, disable_modal);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_app_notification_clicked(
|
||||
this: &mut Object,
|
||||
date: f64,
|
||||
data: id,
|
||||
) {
|
||||
let app = unsafe { get_app(this) };
|
||||
if let Ok(notification_response) =
|
||||
unsafe { super::notification::response_from_native(date as i32, data) }
|
||||
{
|
||||
app.callbacks.notification_clicked(notification_response);
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_did_resign_active(this: &mut Object, _: Sel, _: id) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.app_resigned_active();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_will_terminate(this: &mut Object, _: Sel, _: id) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.app_will_terminate();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_new_window(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.open_new_window();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_active_window_changed(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
Window::close_ime_on_active_window();
|
||||
app.callbacks
|
||||
.active_window_changed(Window::active_window_id());
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_window_did_resize(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.window_resized();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_window_did_move(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.window_moved();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_window_will_close(this: &mut Object, window: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
let window_state = unsafe { get_window_state(window) };
|
||||
app.callbacks.window_will_close(window_state.id());
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_screen_did_change(this: &mut Object) {
|
||||
log::info!("received NSApplicationDidChangeScreenParametersNotification");
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.screen_changed();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn cpu_awakened(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.cpu_awakened();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn cpu_will_sleep(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.cpu_will_sleep();
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_open_files(this: &mut Object, paths: id) {
|
||||
let paths = unsafe {
|
||||
(0..paths.count())
|
||||
.filter_map(|i| {
|
||||
let path = paths.objectAtIndex(i);
|
||||
match CStr::from_ptr(path.UTF8String() as *mut c_char).to_str() {
|
||||
Ok(string) => Some(PathBuf::from(string)),
|
||||
Err(err) => {
|
||||
log::error!("error converting path to string: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.open_files(paths);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_open_urls(this: &mut Object, urls: id) {
|
||||
let urls = unsafe {
|
||||
(0..urls.count())
|
||||
.filter_map(|i| {
|
||||
let url = urls.objectAtIndex(i).absoluteString();
|
||||
match CStr::from_ptr(url.UTF8String() as *mut c_char).to_str() {
|
||||
Ok(string) => Some(string.to_string()),
|
||||
Err(err) => {
|
||||
log::error!("error converting url to string: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.open_urls(urls);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_app_os_appearance_changed(this: &mut Object) {
|
||||
let app = unsafe { get_app(this) };
|
||||
app.callbacks.os_appearance_changed();
|
||||
}
|
||||
|
||||
// Calls the callback with None if no file was selected
|
||||
#[no_mangle]
|
||||
pub(crate) extern "C-unwind" fn warp_open_panel_file_selected(urls: id, callback: *mut c_void) {
|
||||
// Start by converting the callback from a raw pointer back into a Box, to
|
||||
// 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) };
|
||||
|
||||
let paths = unsafe {
|
||||
(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()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if paths.is_empty() {
|
||||
log::info!("No file was selected. Dialog was cancelled.")
|
||||
}
|
||||
|
||||
let app = unsafe { get_app(&mut *get_warp_app()) };
|
||||
app.callbacks.with_mutable_app_context(move |ctx| {
|
||||
callback(Ok(paths), ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Calls the save callback with the selected path or None if cancelled
|
||||
#[no_mangle]
|
||||
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())
|
||||
}
|
||||
};
|
||||
|
||||
if path.is_none() {
|
||||
log::info!("Save dialog was cancelled.");
|
||||
}
|
||||
|
||||
let app = unsafe { get_app(&mut *get_warp_app()) };
|
||||
app.callbacks.with_mutable_app_context(move |ctx| {
|
||||
callback(path, ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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::slice;
|
||||
|
||||
use super::make_nsstring;
|
||||
use galaxyui_core::clipboard::{ClipboardContent, ImageData};
|
||||
|
||||
extern "C" {
|
||||
fn getFilePathsFromPasteboard() -> id;
|
||||
}
|
||||
|
||||
pub struct Clipboard(id);
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
impl crate::Clipboard for Clipboard {
|
||||
fn write(&mut self, contents: ClipboardContent) {
|
||||
unsafe {
|
||||
let nsstr = make_nsstring(&contents.plain_text);
|
||||
self.0
|
||||
.declareTypes_owner(NSArray::arrayWithObject(nil, NSPasteboardTypeString), nil);
|
||||
NSPasteboard::setString_forType(self.0, nsstr, NSPasteboardTypeString);
|
||||
|
||||
if let Some(html) = contents.html {
|
||||
let nsstr = make_nsstring(&html);
|
||||
self.0
|
||||
.addTypes_owner(NSArray::arrayWithObject(nil, NSPasteboardTypeHTML), nil);
|
||||
NSPasteboard::setString_forType(self.0, nsstr, NSPasteboardTypeHTML);
|
||||
}
|
||||
|
||||
if let Some(images) = contents.images {
|
||||
for image in images {
|
||||
let Some(pasteboard_type) =
|
||||
pasteboard_type_for_image_mime_type(&image.mime_type)
|
||||
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,
|
||||
);
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&mut self) -> ClipboardContent {
|
||||
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 available_paths = file_paths.count();
|
||||
|
||||
let text = NSPasteboard::stringForType(self.0, NSPasteboardTypeString);
|
||||
let mut content = ClipboardContent::plain_text(if text != nil {
|
||||
CStr::from_ptr(text.UTF8String())
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else {
|
||||
String::from("")
|
||||
});
|
||||
|
||||
if available_paths > 0 {
|
||||
content.paths = Some(
|
||||
(0..available_paths)
|
||||
.map(|i| {
|
||||
let directory = file_paths.objectAtIndex(i);
|
||||
let slice = slice::from_raw_parts(
|
||||
directory.UTF8String() as *const c_uchar,
|
||||
directory.len(),
|
||||
);
|
||||
std::str::from_utf8_unchecked(slice).to_string()
|
||||
})
|
||||
.collect::<Vec<String>>(),
|
||||
);
|
||||
}
|
||||
|
||||
let html = NSPasteboard::stringForType(self.0, NSPasteboardTypeHTML);
|
||||
if html != nil {
|
||||
content.html = Some(
|
||||
CStr::from_ptr(html.UTF8String())
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
// Try to read image data from clipboard
|
||||
content.images = self.read_image_data_from_pasteboard();
|
||||
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
/// Reads image data from the macOS pasteboard.
|
||||
///
|
||||
/// Checks for supported image formats and returns the first available image
|
||||
/// data found, prioritizing common web-compatible formats.
|
||||
fn read_image_data_from_pasteboard(&self) -> Option<Vec<ImageData>> {
|
||||
unsafe {
|
||||
// Check for common image types on macOS pasteboard
|
||||
// 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"),
|
||||
];
|
||||
|
||||
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);
|
||||
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("")
|
||||
{
|
||||
"public.png" => "image/png",
|
||||
"public.jpeg" => "image/jpeg",
|
||||
"public.gif" | "com.compuserve.gif" => "image/gif",
|
||||
"public.webp" => "image/webp",
|
||||
"public.svg-image" => "image/svg+xml",
|
||||
_ => "image/unknown",
|
||||
};
|
||||
|
||||
// Try to extract filename from HTML content if available
|
||||
let filename = {
|
||||
let html = NSPasteboard::stringForType(self.0, NSPasteboardTypeHTML);
|
||||
if html != nil {
|
||||
let html_str =
|
||||
CStr::from_ptr(html.UTF8String()).to_str().unwrap_or("");
|
||||
if !html_str.is_empty() {
|
||||
crate::clipboard_utils::extract_filename_from_html(html_str)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
images.push(ImageData {
|
||||
data: bytes.to_vec(),
|
||||
mime_type: mime_type.to_string(),
|
||||
filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(images)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clipboard_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Memory-behavior repro for APP-4154 batch 1.C (galaxyui-platform-nsstring).
|
||||
//!
|
||||
//! Exercises the `NSString::alloc(nil).init_str(...)` → `make_nsstring(...)` conversions
|
||||
//! applied to `pasteboard_type_for_image_mime_type` and related clipboard
|
||||
//! helpers. The helper is shared by every retained-NSString site in this file
|
||||
//! (6 in `read_image_data_from_pasteboard`, 2 in `Clipboard::write`, and this
|
||||
//! one in `pasteboard_type_for_image_mime_type`), so it is representative for
|
||||
//! the whole file.
|
||||
//!
|
||||
//! On master the raw `NSString::alloc(nil).init_str(...)` returns a string with
|
||||
//! a +1 retain count that no pool drain can clean up, so even inside an outer
|
||||
//! `NSAutoreleasePool` the string survives past `pool.drain()` and memory grows
|
||||
//! linearly with iteration count.
|
||||
//!
|
||||
//! On the PR branch `make_nsstring` autoreleases, so every string returned here
|
||||
//! is released when the surrounding pool drains and peak RSS stays flat across
|
||||
//! outer iterations.
|
||||
//!
|
||||
//! Run as:
|
||||
//! cargo test --release -p galaxyui \
|
||||
//! pasteboard_type_for_image_mime_type_memory_behavior -- --nocapture --ignored
|
||||
//! and measure peak RSS with `/usr/bin/time -l`.
|
||||
use cocoa::base::nil;
|
||||
use cocoa::foundation::NSAutoreleasePool;
|
||||
|
||||
use super::pasteboard_type_for_image_mime_type;
|
||||
|
||||
/// Number of outer pool cycles. Each cycle creates an `NSAutoreleasePool`,
|
||||
/// runs the inner loop, then drains. On master the retained NSStrings survive
|
||||
/// the drain, so memory usage grows proportionally to OUTER * INNER.
|
||||
const OUTER: usize = 60;
|
||||
|
||||
/// Number of inner iterations per pool cycle. Must be large enough to produce
|
||||
/// a measurable RSS delta but small enough to fit easily in memory on the
|
||||
/// branch side (where strings are reclaimed per cycle).
|
||||
const INNER: usize = 20_000;
|
||||
|
||||
const MIME_TYPES: &[&str] = &[
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
];
|
||||
|
||||
#[test]
|
||||
#[ignore = "memory repro; run with --ignored --nocapture in release mode"]
|
||||
fn pasteboard_type_for_image_mime_type_memory_behavior() {
|
||||
unsafe {
|
||||
for _ in 0..OUTER {
|
||||
let pool = NSAutoreleasePool::new(nil);
|
||||
for _ in 0..INNER {
|
||||
for mime in MIME_TYPES {
|
||||
let ns = pasteboard_type_for_image_mime_type(mime);
|
||||
assert!(ns.is_some(), "mime {mime} should be mapped");
|
||||
}
|
||||
}
|
||||
pool.drain();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
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 anyhow::Result;
|
||||
use cocoa::base::{BOOL, NO, YES};
|
||||
use cocoa::foundation::NSUInteger;
|
||||
use cocoa::{
|
||||
appkit::{NSApp, NSRequestUserAttentionType},
|
||||
base::{id, nil},
|
||||
};
|
||||
use objc::{class, msg_send, sel, sel_impl};
|
||||
use std::ffi::c_void;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
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::platform::{
|
||||
Cursor, FilePickerCallback, FilePickerConfiguration, MicrophoneAccessState,
|
||||
SendNotificationErrorCallback, TerminationMode,
|
||||
};
|
||||
use galaxyui_core::ApplicationBundleInfo;
|
||||
use galaxyui_core::{
|
||||
accessibility::AccessibilityContent, notification::UserNotification, platform, WindowId,
|
||||
};
|
||||
|
||||
// Functions implemented in objC files.
|
||||
extern "C" {
|
||||
// Requests permissions to send desktop notifications.
|
||||
fn requestNotificationPermissions(on_completion_callback: *const c_void);
|
||||
// Sends a desktop notification.
|
||||
fn sendNotification(
|
||||
title: id,
|
||||
body: id,
|
||||
data: id,
|
||||
on_error_callback: *const c_void,
|
||||
play_sound: BOOL,
|
||||
);
|
||||
fn isDarkMode() -> BOOL;
|
||||
fn registerGlobalHotkey(key_code: NSUInteger, modifiers_key: NSUInteger);
|
||||
fn unregisterGlobalHotkey(key_code: NSUInteger, modifiers_key: NSUInteger);
|
||||
fn executableInApplicationBundleWithIdentifier(bundle_path: id) -> id;
|
||||
fn absolutePathForApplicationBundleWithIdentifier(bundle_identifier: id) -> id;
|
||||
fn isVoiceOverEnabled() -> BOOL;
|
||||
}
|
||||
|
||||
type RequestNotificationPermissionsCallback = Box<dyn FnOnce(RequestPermissionsOutcome) + Send>;
|
||||
type NotificationSendErrorCallback = Box<dyn FnOnce(NotificationSendError) + Send>;
|
||||
|
||||
/// Delegator that wraps platform-specific calls in a common API.
|
||||
pub struct AppDelegate {
|
||||
clipboard: Clipboard,
|
||||
dispatch_delegate: Arc<DispatchDelegate>,
|
||||
}
|
||||
|
||||
pub struct IntegrationTestDelegate {
|
||||
app_delegate: AppDelegate,
|
||||
clipboard: InMemoryClipboard,
|
||||
}
|
||||
|
||||
impl IntegrationTestDelegate {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(IntegrationTestDelegate {
|
||||
app_delegate: AppDelegate::new()?,
|
||||
clipboard: InMemoryClipboard::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl platform::Delegate for IntegrationTestDelegate {
|
||||
#[cfg(feature = "test-util")]
|
||||
fn get_cursor_shape(&self) -> Cursor {
|
||||
self.app_delegate.get_cursor_shape()
|
||||
}
|
||||
|
||||
fn set_cursor_shape(&self, cursor: Cursor) {
|
||||
self.app_delegate.set_cursor_shape(cursor)
|
||||
}
|
||||
|
||||
fn open_url(&self, _: &str) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn open_file_path(&self, _: &Path) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn open_file_path_in_explorer(&self, _: &Path) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn open_file_picker(
|
||||
&self,
|
||||
_callback: FilePickerCallback,
|
||||
_file_picker_config: FilePickerConfiguration,
|
||||
) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn open_save_file_picker(
|
||||
&self,
|
||||
_callback: platform::SaveFilePickerCallback,
|
||||
_config: platform::SaveFilePickerConfiguration,
|
||||
) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn application_bundle_info(&self, _: &str) -> Option<ApplicationBundleInfo<'_>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn close_ime_async(&self, _window_id: WindowId) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn is_ime_open(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn open_character_palette(&self) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn set_accessibility_contents(&self, _: AccessibilityContent) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn request_user_attention(&self, _window_id: WindowId) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn clipboard(&mut self) -> &mut dyn crate::Clipboard {
|
||||
&mut self.clipboard
|
||||
}
|
||||
|
||||
fn request_desktop_notification_permissions(
|
||||
&self,
|
||||
_on_completion: platform::RequestNotificationPermissionsCallback,
|
||||
) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
fn send_desktop_notification(
|
||||
&self,
|
||||
_notification_content: UserNotification,
|
||||
_window_id: WindowId,
|
||||
_on_error: SendNotificationErrorCallback,
|
||||
) {
|
||||
}
|
||||
|
||||
fn system_theme(&self) -> platform::SystemTheme {
|
||||
self.app_delegate.system_theme()
|
||||
}
|
||||
|
||||
fn dispatch_delegate(&self) -> Arc<dyn platform::DispatchDelegate> {
|
||||
self.app_delegate.dispatch_delegate()
|
||||
}
|
||||
|
||||
fn register_global_shortcut(&self, shortcut: Keystroke) {
|
||||
self.app_delegate.register_global_shortcut(shortcut)
|
||||
}
|
||||
|
||||
fn unregister_global_shortcut(&self, shortcut: &Keystroke) {
|
||||
self.app_delegate.unregister_global_shortcut(shortcut)
|
||||
}
|
||||
|
||||
fn terminate_app(&self, termination_mode: TerminationMode) {
|
||||
self.app_delegate.terminate_app(termination_mode);
|
||||
}
|
||||
|
||||
fn is_screen_reader_enabled(&self) -> Option<bool> {
|
||||
self.app_delegate.is_screen_reader_enabled()
|
||||
}
|
||||
|
||||
fn microphone_access_state(&self) -> MicrophoneAccessState {
|
||||
self.app_delegate.microphone_access_state()
|
||||
}
|
||||
|
||||
fn show_native_platform_modal(&self, _id: ModalId, _modal: AlertDialog) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DispatchDelegate;
|
||||
|
||||
impl AppDelegate {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(AppDelegate {
|
||||
clipboard: Clipboard::new()?,
|
||||
dispatch_delegate: Arc::new(DispatchDelegate),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-util")]
|
||||
fn get_cursor_shape(&self) -> Cursor {
|
||||
unimplemented!("only implemented in tests")
|
||||
}
|
||||
fn open_url(&self, url: &str) {
|
||||
Window::open_url(url);
|
||||
}
|
||||
|
||||
fn open_file_path(&self, path: &Path) {
|
||||
Window::open_file_path(path);
|
||||
}
|
||||
|
||||
fn open_file_path_in_explorer(&self, path: &Path) {
|
||||
Window::open_file_path_in_explorer(path);
|
||||
}
|
||||
|
||||
fn open_file_picker(
|
||||
&self,
|
||||
callback: FilePickerCallback,
|
||||
file_picker_config: FilePickerConfiguration,
|
||||
) {
|
||||
Window::open_file_picker(callback, file_picker_config);
|
||||
}
|
||||
|
||||
fn open_save_file_picker(
|
||||
&self,
|
||||
callback: platform::SaveFilePickerCallback,
|
||||
config: platform::SaveFilePickerConfiguration,
|
||||
) {
|
||||
Window::open_save_file_picker(callback, config);
|
||||
}
|
||||
|
||||
fn application_bundle_info(
|
||||
&self,
|
||||
bundle_identifier: &str,
|
||||
) -> Option<ApplicationBundleInfo<'_>> {
|
||||
let bundle_path = unsafe {
|
||||
let nsstring =
|
||||
absolutePathForApplicationBundleWithIdentifier(make_nsstring(bundle_identifier));
|
||||
|
||||
if nsstring == nil {
|
||||
return None;
|
||||
}
|
||||
|
||||
nsstring_as_str(nsstring).ok()?
|
||||
};
|
||||
|
||||
let executable_path = unsafe {
|
||||
let nsstring = executableInApplicationBundleWithIdentifier(make_nsstring(bundle_path));
|
||||
|
||||
if nsstring == nil {
|
||||
None
|
||||
} else {
|
||||
nsstring_as_str(nsstring).map(Path::new).ok()
|
||||
}
|
||||
};
|
||||
|
||||
Some(ApplicationBundleInfo {
|
||||
path: Path::new(bundle_path),
|
||||
executable: executable_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the macOS character palette.
|
||||
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 {
|
||||
// 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];
|
||||
});
|
||||
}
|
||||
|
||||
fn close_ime_async(&self, window_id: WindowId) {
|
||||
Window::close_ime_async(window_id);
|
||||
}
|
||||
|
||||
fn is_ime_open(&self) -> bool {
|
||||
Window::is_ime_open()
|
||||
}
|
||||
|
||||
fn set_accessibility_contents(&self, content: AccessibilityContent) {
|
||||
Window::set_accessibility_contents(content);
|
||||
}
|
||||
|
||||
fn request_user_attention(&self, _window_id: WindowId) {
|
||||
unsafe {
|
||||
let () = msg_send![
|
||||
NSApp(),
|
||||
requestUserAttention: NSRequestUserAttentionType::NSInformationalRequest
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
fn request_desktop_notification_permissions(
|
||||
&self,
|
||||
on_completion_callback: platform::RequestNotificationPermissionsCallback,
|
||||
) {
|
||||
unsafe {
|
||||
let callback: RequestNotificationPermissionsCallback = Box::new(|outcome| {
|
||||
app::callback_dispatcher().with_mutable_app_context(|ctx| {
|
||||
on_completion_callback(outcome, ctx);
|
||||
})
|
||||
});
|
||||
requestNotificationPermissions(Box::into_raw(Box::new(callback)) as *const c_void);
|
||||
};
|
||||
}
|
||||
|
||||
fn send_desktop_notification(
|
||||
&self,
|
||||
notification_content: UserNotification,
|
||||
_window_id: WindowId,
|
||||
on_error_callback: SendNotificationErrorCallback,
|
||||
) {
|
||||
unsafe {
|
||||
let callback: NotificationSendErrorCallback = Box::new(|error| {
|
||||
app::callback_dispatcher().with_mutable_app_context(|ctx| {
|
||||
on_error_callback(error, ctx);
|
||||
})
|
||||
});
|
||||
sendNotification(
|
||||
make_nsstring(notification_content.title()),
|
||||
make_nsstring(notification_content.body()),
|
||||
make_nsstring(notification_content.data().unwrap_or_default()),
|
||||
Box::into_raw(Box::new(callback)) as *const c_void,
|
||||
if notification_content.play_sound() {
|
||||
YES
|
||||
} else {
|
||||
NO
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
fn clipboard(&mut self) -> &mut dyn crate::Clipboard {
|
||||
&mut self.clipboard
|
||||
}
|
||||
|
||||
fn system_theme(&self) -> platform::SystemTheme {
|
||||
unsafe {
|
||||
let dark_mode = isDarkMode();
|
||||
if dark_mode == YES {
|
||||
platform::SystemTheme::Dark
|
||||
} else {
|
||||
platform::SystemTheme::Light
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delegate(&self) -> Arc<dyn platform::DispatchDelegate> {
|
||||
self.dispatch_delegate.clone()
|
||||
}
|
||||
|
||||
fn show_native_platform_modal(&self, id: ModalId, modal: AlertDialog) {
|
||||
let alert = create_native_platform_modal(modal);
|
||||
unsafe {
|
||||
let _: () = msg_send![app::get_warp_app(), showModal: alert modalId: id];
|
||||
}
|
||||
}
|
||||
|
||||
fn register_global_shortcut(&self, shortcut: Keystroke) {
|
||||
unsafe {
|
||||
for shortcut_key in Keycode::keycodes_from_key_name(&shortcut.key) {
|
||||
registerGlobalHotkey(shortcut_key.0.into(), modifier_code(&shortcut).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unregister_global_shortcut(&self, shortcut: &Keystroke) {
|
||||
unsafe {
|
||||
for shortcut_key in Keycode::keycodes_from_key_name(&shortcut.key) {
|
||||
unregisterGlobalHotkey(shortcut_key.0.into(), modifier_code(shortcut).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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];
|
||||
}
|
||||
TerminationMode::Cancellable => {}
|
||||
}
|
||||
let _: () = msg_send![NSApp(), terminate: nil];
|
||||
});
|
||||
}
|
||||
|
||||
fn is_screen_reader_enabled(&self) -> Option<bool> {
|
||||
unsafe { Some(isVoiceOverEnabled() == YES) }
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
/// # Safety
|
||||
/// This function is marked unsafe because it retrieves the pointer to the callback
|
||||
/// function that we sent down to the Objective-C code.
|
||||
pub unsafe extern "C-unwind" fn warp_on_request_notification_permissions_completed(
|
||||
result_type: NSUInteger,
|
||||
result_msg: id,
|
||||
callback: *mut c_void,
|
||||
) {
|
||||
let outcome =
|
||||
super::notification::request_permissions_outcome_from_native(result_type, result_msg);
|
||||
if let Ok(outcome) = outcome {
|
||||
let callback = Box::from_raw(callback as *mut RequestNotificationPermissionsCallback);
|
||||
callback(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
/// # Safety
|
||||
/// This function is marked unsafe because it retrieves the pointer to the callback
|
||||
/// function that we sent down to the Objective-C code.
|
||||
pub unsafe extern "C-unwind" fn warp_on_notification_send_error(
|
||||
error_type: NSUInteger,
|
||||
error_msg: id,
|
||||
callback: *mut c_void,
|
||||
) {
|
||||
let notification_error = super::notification::send_error_from_native(error_type, error_msg);
|
||||
if let Ok(notification_error) = notification_error {
|
||||
let callback = Box::from_raw(callback as *mut NotificationSendErrorCallback);
|
||||
callback(notification_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
|
||||
}
|
||||
|
||||
fn run_on_main_thread(&self, task: async_task::Runnable) {
|
||||
dispatch::Queue::main().exec_async(move || {
|
||||
task.run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use cocoa::foundation::NSUInteger;
|
||||
use std::{ffi::CStr, os::raw::c_char};
|
||||
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use super::{
|
||||
keycode::{scancode_to_physicalkey, Keycode},
|
||||
utils::unicode_char_to_key,
|
||||
};
|
||||
|
||||
// Unpublished but widely known and stable flags for distinguishing left/right alt.
|
||||
// Google "NX_DEVICELALTKEYMASK" for more.
|
||||
const LEFT_ALT_MASK: NSUInteger = 0x00000020;
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
fn native_key_code_to_key_code(native_key_code: u16) -> Option<KeyCode> {
|
||||
let physical_key = scancode_to_physicalkey(native_key_code as u32);
|
||||
match physical_key {
|
||||
PhysicalKey::Code(key_code) => Some(key_code),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
/// This code is only unsafe since it requires interfacing with platform code.
|
||||
/// Creates an event from a native event, taking in the current window_height and whether this is
|
||||
/// the first mouse event on an inactive window that is causing the window to activate.
|
||||
pub unsafe fn from_native(
|
||||
native_event: id,
|
||||
window_height: Option<f32>,
|
||||
is_first_mouse: bool,
|
||||
) -> Option<Event> {
|
||||
let event_type = native_event.eventType();
|
||||
|
||||
// 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 {
|
||||
0 | 21 | 32 | 33 | 35 | 36 | 37 => {
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let modifiers = modifier_flags_to_state(native_event.modifierFlags());
|
||||
|
||||
match event_type {
|
||||
NSEventType::NSKeyDown => {
|
||||
let native_modifiers = native_event.modifierFlags();
|
||||
|
||||
// Get the base character for this key without any modifiers (including Shift)
|
||||
// using UCKeyTranslate via the platform's keyCodeToChar function.
|
||||
// For example, Shift+1 on a US keyboard gives '!' as the key, but
|
||||
// key_without_modifiers will be '1'.
|
||||
let key_without_modifiers = Keycode(native_event.keyCode()).try_to_key_name(false);
|
||||
|
||||
let details = KeyEventDetails {
|
||||
left_alt: (native_modifiers.bits() & LEFT_ALT_MASK) != 0,
|
||||
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)
|
||||
.to_str()
|
||||
.ok()?;
|
||||
|
||||
let unmodified_chars = if let Some(first_char) = unmodified_chars.chars().next() {
|
||||
unicode_char_to_key(first_char as u16).unwrap_or(unmodified_chars)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
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),
|
||||
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()
|
||||
};
|
||||
|
||||
Some(Event::KeyDown {
|
||||
keystroke,
|
||||
chars,
|
||||
details,
|
||||
is_composing: false,
|
||||
})
|
||||
}
|
||||
NSEventType::NSMouseMoved => 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),
|
||||
shift: native_event
|
||||
.modifierFlags()
|
||||
.contains(NSEventModifierFlags::NSShiftKeyMask),
|
||||
is_synthetic: false,
|
||||
}),
|
||||
NSEventType::NSFlagsChanged => {
|
||||
let key_code = native_key_code_to_key_code(native_event.keyCode());
|
||||
|
||||
window_height.map(|window_height| Event::ModifierStateChanged {
|
||||
mouse_position: vec2f(
|
||||
native_event.locationInWindow().x as f32,
|
||||
window_height - native_event.locationInWindow().y as f32,
|
||||
),
|
||||
modifiers,
|
||||
key_code,
|
||||
})
|
||||
}
|
||||
NSEventType::NSLeftMouseDown => window_height.map(|window_height| {
|
||||
let position = vec2f(
|
||||
native_event.locationInWindow().x as f32,
|
||||
window_height - native_event.locationInWindow().y as f32,
|
||||
);
|
||||
let click_count = native_event.clickCount() as u32;
|
||||
|
||||
// ctrl-click should actually be registered as a right-click
|
||||
// https://support.apple.com/guide/mac-help/right-click-mh35853/mac
|
||||
if modifiers.ctrl {
|
||||
Event::RightMouseDown {
|
||||
position,
|
||||
cmd: modifiers.cmd,
|
||||
shift: modifiers.shift,
|
||||
click_count,
|
||||
}
|
||||
} else {
|
||||
Event::LeftMouseDown {
|
||||
position,
|
||||
modifiers,
|
||||
click_count,
|
||||
is_first_mouse,
|
||||
}
|
||||
}
|
||||
}),
|
||||
NSEventType::NSLeftMouseUp => 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 => {
|
||||
window_height.map(|window_height| Event::LeftMouseDragged {
|
||||
position: vec2f(
|
||||
native_event.locationInWindow().x as f32,
|
||||
window_height - native_event.locationInWindow().y as f32,
|
||||
),
|
||||
modifiers,
|
||||
})
|
||||
}
|
||||
// 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 => {
|
||||
let window_height = window_height?;
|
||||
let window_location = native_event.locationInWindow();
|
||||
let position = vec2f(
|
||||
window_location.x as f32,
|
||||
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 click_count = native_event.clickCount() as u32;
|
||||
|
||||
match native_event.buttonNumber() {
|
||||
2 => Some(Event::MiddleMouseDown {
|
||||
position,
|
||||
cmd,
|
||||
shift,
|
||||
click_count,
|
||||
}),
|
||||
3 => Some(Event::BackMouseDown {
|
||||
position,
|
||||
cmd,
|
||||
shift,
|
||||
click_count,
|
||||
}),
|
||||
4 => Some(Event::ForwardMouseDown {
|
||||
position,
|
||||
cmd,
|
||||
shift,
|
||||
click_count,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// For trackpads, this event will get triggered by the user's secondary click setting.
|
||||
NSEventType::NSRightMouseDown => 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),
|
||||
shift: native_event
|
||||
.modifierFlags()
|
||||
.contains(NSEventModifierFlags::NSShiftKeyMask),
|
||||
click_count: native_event.clickCount() as u32,
|
||||
}),
|
||||
NSEventType::NSScrollWheel => window_height.map(|window_height| Event::ScrollWheel {
|
||||
position: vec2f(
|
||||
native_event.locationInWindow().x as f32,
|
||||
window_height - native_event.locationInWindow().y as f32,
|
||||
),
|
||||
delta: vec2f(
|
||||
native_event.scrollingDeltaX() as f32,
|
||||
native_event.scrollingDeltaY() as f32,
|
||||
),
|
||||
precise: native_event.hasPreciseScrollingDeltas() == YES,
|
||||
modifiers,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
use super::text_layout::{layout_line, layout_text};
|
||||
use crate::fonts::font_kit::{properties_to_font_kit, Rasterizer};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use core_foundation::array::{CFArray, CFArrayRef};
|
||||
use core_foundation::base::{CFType, ItemRef, TCFType};
|
||||
use core_foundation::dictionary::CFDictionary;
|
||||
use core_foundation::string::{CFString, CFStringRef, UniChar};
|
||||
use core_graphics::display::CGSize;
|
||||
use core_graphics::font::CGGlyph;
|
||||
use core_text::font::{cascade_list_for_languages as ct_cascade_list_for_languages, CTFont};
|
||||
use core_text::font_descriptor::{
|
||||
kCTFontFamilyNameAttribute, kCTFontLanguagesAttribute, kCTFontNameAttribute,
|
||||
kCTFontOrientationHorizontal, CTFontDescriptor, CTFontDescriptorCopyAttribute,
|
||||
SymbolicTraitAccessors, TraitAccessors,
|
||||
};
|
||||
use core_text::{font, font_collection, font_descriptor};
|
||||
use dashmap::{mapref::entry::Entry, DashMap};
|
||||
use font_kit::font::Font;
|
||||
use font_kit::loaders::core_text::NativeFont;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt as _;
|
||||
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 galaxyui_core::fonts::{
|
||||
canvas::RasterFormat, FamilyId, FontId, FontInfo, GlyphId, Metrics, Properties,
|
||||
RasterizedGlyph, SubpixelAlignment,
|
||||
};
|
||||
use galaxyui_core::platform::{self, FontDB as _, LineStyle, TextLayoutSystem};
|
||||
use galaxyui_core::rendering;
|
||||
use galaxyui_core::text_layout::{ClipConfig, StyleAndFont, TextAlignment, TextFrame};
|
||||
|
||||
struct FontFamily {
|
||||
name: String,
|
||||
fonts: Vec<Font>,
|
||||
}
|
||||
|
||||
mod loader {
|
||||
use super::*;
|
||||
|
||||
// Font-kit loads fonts by copying their font file into the running memory, which
|
||||
// is extremely inefficient. Thus, for system fonts, we load these fonts by reference
|
||||
// through CTFontDescriptorCreateWithAttributes function and create a dummy font-kit
|
||||
// Font interface to access its functions.
|
||||
pub fn load_system_font(font_family: &str) -> Result<FontFamily> {
|
||||
let Some(descriptors) = FontDB::descriptors_for_family(font_family) else {
|
||||
bail!(
|
||||
"could not find a non-empty font family matching one of the given names {:?}",
|
||||
font_family
|
||||
);
|
||||
};
|
||||
let mut fonts = Vec::with_capacity(descriptors.len() as usize);
|
||||
for fontdesc in descriptors.into_iter() {
|
||||
// The font size here does not affect our rendering. In CTFont, pt_size
|
||||
// is used to calculate font metrics like ascent, descent, etc. However,
|
||||
// font-kit creates its own layer of calculating font metrics at render time
|
||||
// so we just need a place-holder here for getting the CTFont object. Use
|
||||
// 16.0 here as it is consistent with https://docs.rs/core-text/19.2.0/src/core_text/font.rs.html#130
|
||||
let font = Font::from_ct_font(font::new_from_descriptor(&fontdesc, DEFAULT_FONT_SIZE));
|
||||
|
||||
let glyph_id = font.glyph_for_char('m');
|
||||
if glyph_id.is_none() {
|
||||
return Err(anyhow!("font must contain a glyph for the 'm' character"));
|
||||
}
|
||||
|
||||
fonts.push(font);
|
||||
}
|
||||
Ok(FontFamily {
|
||||
fonts,
|
||||
name: font_family.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_all_system_fonts() -> LoadedSystemFonts {
|
||||
let collection = font_collection::create_for_all_families();
|
||||
let Some(descriptors) = collection.get_descriptors() else {
|
||||
return LoadedSystemFonts(vec![]);
|
||||
};
|
||||
|
||||
let mut fonts: Vec<(FontInfo, FontFamily)> = Vec::with_capacity(descriptors.len() as usize);
|
||||
for descriptor in descriptors.iter() {
|
||||
let name = match unsafe { FontDB::get_family_name(&descriptor) } {
|
||||
Some(family) => family,
|
||||
None => {
|
||||
log::warn!("Failed to load the font as it does not have a valid family name.");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let internal_name = match unsafe { FontDB::get_font_name(&descriptor) } {
|
||||
Some(family) => family,
|
||||
None => {
|
||||
log::warn!("Failed to load the font as it does not have a valid font name.");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// We should only load languages that support english
|
||||
if !FontDB::supports_english(&descriptor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(idx) = fonts.iter().position(|font| font.0.family_name == name) {
|
||||
// Some font families (e.g. Osaka) could contains both monospace
|
||||
// and variable-width fonts. To make sure the returned font family
|
||||
// info is consistent everytime, we set is_monospace to true if the
|
||||
// family contains one font that is monospace.
|
||||
if descriptor.traits().symbolic_traits().is_monospace()
|
||||
&& !fonts[idx].0.is_monospace
|
||||
{
|
||||
// Updating here since font family names are guaranteed to be unique.
|
||||
fonts[idx].0.is_monospace = true;
|
||||
}
|
||||
// Since we keep track of font families, add this font as a possible style.
|
||||
fonts[idx].0.font_names.push(internal_name)
|
||||
} else {
|
||||
let font_family = match load_system_font(&name) {
|
||||
Ok(font) => font,
|
||||
Err(err) => {
|
||||
log::debug!("Failed to load {}: {:?}", name.as_str(), err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
fonts.push((
|
||||
FontInfo {
|
||||
family_name: name,
|
||||
font_names: vec![internal_name],
|
||||
is_monospace: descriptor.traits().symbolic_traits().is_monospace(),
|
||||
},
|
||||
font_family,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
LoadedSystemFonts(fonts)
|
||||
}
|
||||
|
||||
// We use font-kit's family handle to load fonts that come with Warp as
|
||||
// these binaries are already in memory and won't increase our memory load.
|
||||
pub fn load_font_family_from_bytes(name: &str, font_bytes: Vec<Vec<u8>>) -> Result<FontFamily> {
|
||||
let mut fonts = Vec::with_capacity(font_bytes.len());
|
||||
|
||||
for font in font_bytes {
|
||||
let Ok(font) = Font::from_bytes(Arc::new(font), 0) else {
|
||||
log::info!("Unable to parse font bytes for font {name:?}");
|
||||
continue;
|
||||
};
|
||||
|
||||
let glyph_id = font.glyph_for_char('m');
|
||||
if glyph_id.is_none() {
|
||||
return Err(anyhow!("font must contain a glyph for the 'm' character"));
|
||||
}
|
||||
fonts.push(font);
|
||||
}
|
||||
|
||||
Ok(FontFamily {
|
||||
fonts,
|
||||
name: name.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadedSystemFonts(Vec<(FontInfo, FontFamily)>);
|
||||
|
||||
impl platform::LoadedSystemFonts for LoadedSystemFonts {
|
||||
fn as_any(self: Box<Self>) -> Box<dyn Any> {
|
||||
self as Box<dyn Any>
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FontDB {
|
||||
next_family_id: AtomicUsize,
|
||||
families: HashMap<FamilyId, Family>,
|
||||
next_font_id: AtomicUsize,
|
||||
rasterizer: Rasterizer,
|
||||
font_names: DashMap<FontId, Arc<String>>,
|
||||
native_fonts: DashMap<(FontId, OrderedFloat<f32>), NativeFont>,
|
||||
fonts_by_name: DashMap<Arc<String>, FontId>,
|
||||
fallback_fonts: DashMap<FontId, Arc<Vec<FontId>>>,
|
||||
metrics: DashMap<FontId, Metrics>,
|
||||
font_selections: DashMap<(FamilyId, Properties), FontId>,
|
||||
space_advances: DashMap<FontId, Option<f64>>,
|
||||
}
|
||||
|
||||
struct Family {
|
||||
name: String,
|
||||
font_ids: Vec<FontId>,
|
||||
}
|
||||
|
||||
impl Default for FontDB {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_FONT_SIZE: f64 = 16.0;
|
||||
|
||||
// Returns the horizontal advance (in points) of a single space in the given font.
|
||||
fn space_advance_width(font: &CTFont) -> Option<f64> {
|
||||
let space_char: UniChar = ' ' as u16;
|
||||
let mut glyph: CGGlyph = 0;
|
||||
|
||||
let ok =
|
||||
unsafe { font.get_glyphs_for_characters(&space_char as *const UniChar, &mut glyph, 1) };
|
||||
if !ok || glyph == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut advance = CGSize {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
};
|
||||
unsafe {
|
||||
font.get_advances_for_glyphs(
|
||||
kCTFontOrientationHorizontal,
|
||||
&glyph as *const CGGlyph,
|
||||
&mut advance as *mut CGSize,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
let width = advance.width;
|
||||
(width.is_finite() && width > 0.0).then_some(width)
|
||||
}
|
||||
|
||||
impl FontDB {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
next_family_id: Default::default(),
|
||||
families: Default::default(),
|
||||
next_font_id: Default::default(),
|
||||
rasterizer: Rasterizer::new(),
|
||||
font_names: Default::default(),
|
||||
native_fonts: Default::default(),
|
||||
fonts_by_name: Default::default(),
|
||||
fallback_fonts: Default::default(),
|
||||
metrics: Default::default(),
|
||||
font_selections: Default::default(),
|
||||
space_advances: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// This functions the same as the family_name method in core text font descriptor, but it returns
|
||||
// None instead of panicking when the descriptor does not include the family_name attribute.
|
||||
unsafe fn get_family_name(descriptor: &ItemRef<CTFontDescriptor>) -> Option<String> {
|
||||
let value = CTFontDescriptorCopyAttribute(
|
||||
descriptor.as_concrete_TypeRef(),
|
||||
kCTFontFamilyNameAttribute,
|
||||
);
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value = CFType::wrap_under_create_rule(value);
|
||||
let s = CFString::wrap_under_get_rule(value.as_CFTypeRef() as CFStringRef);
|
||||
Some(s.to_string())
|
||||
}
|
||||
|
||||
unsafe fn get_font_name(descriptor: &ItemRef<CTFontDescriptor>) -> Option<String> {
|
||||
let value =
|
||||
CTFontDescriptorCopyAttribute(descriptor.as_concrete_TypeRef(), kCTFontNameAttribute);
|
||||
if value.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value = CFType::wrap_under_create_rule(value);
|
||||
let s = CFString::wrap_under_get_rule(value.as_CFTypeRef() as CFStringRef);
|
||||
Some(s.to_string())
|
||||
}
|
||||
|
||||
pub fn fallback_fonts(&self, font_id: FontId) -> Vec<FontId> {
|
||||
self.fallback_fonts
|
||||
.get(&font_id)
|
||||
.expect("Font fallback should not be empty")
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
// Check if the font family supports english.
|
||||
fn supports_english(descriptor: &ItemRef<CTFontDescriptor>) -> bool {
|
||||
unsafe {
|
||||
let value = CTFontDescriptorCopyAttribute(
|
||||
descriptor.as_concrete_TypeRef(),
|
||||
kCTFontLanguagesAttribute,
|
||||
) as CFArrayRef;
|
||||
|
||||
if value.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let languages: CFArray<CFString> = CFArray::wrap_under_create_rule(value);
|
||||
languages.iter().any(|s| *s == "en")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_family_name_from_id(&self, id: FamilyId) -> Option<String> {
|
||||
self.families.get(&id).map(|s| s.name.clone())
|
||||
}
|
||||
|
||||
fn create_new_family_id(&self) -> FamilyId {
|
||||
FamilyId(self.next_family_id.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
/// Return fallback descriptors for font/language list.
|
||||
/// Heavily inspired by crossfont's implementation:
|
||||
/// https://github.com/alacritty/crossfont/blob/d3515de22494c6fa70d84d2a9264c10097e303bd/src/darwin/mod.rs#L288
|
||||
fn cascade_list_for_languages(&self, ct_font: &CTFont, languages: &[&str]) -> Vec<FontId> {
|
||||
// Convert language type &Vec<String> -> CFArray.
|
||||
let langarr: CFArray<CFString> = {
|
||||
let tmp: Vec<CFString> = languages
|
||||
.iter()
|
||||
.map(|language| CFString::new(language))
|
||||
.collect();
|
||||
CFArray::from_CFTypes(&tmp)
|
||||
};
|
||||
|
||||
// CFArray of CTFontDescriptorRef (again).
|
||||
let list = ct_cascade_list_for_languages(ct_font, &langarr);
|
||||
|
||||
let mut fallback_fonts: Vec<FontId> = list
|
||||
.into_iter()
|
||||
.filter_map(|fontdesc| self.descriptor_to_font_id(fontdesc))
|
||||
.collect();
|
||||
|
||||
// While .Apple Symbols Fallback is not a valid font. Apple Symbols is and it provides
|
||||
// many fallback characters. This implementation is consistent with Alacritty:
|
||||
// See: https://github.com/alacritty/crossfont/blob/d3515de22494c6fa70d84d2a9264c10097e303bd/src/darwin/mod.rs#L91
|
||||
if let Some(font) = FontDB::descriptors_for_family("Apple Symbols")
|
||||
.as_ref()
|
||||
.and_then(|descriptor| descriptor.into_iter().next())
|
||||
.and_then(|font_descriptor| self.descriptor_to_font_id(font_descriptor))
|
||||
{
|
||||
fallback_fonts.push(font);
|
||||
}
|
||||
|
||||
fallback_fonts
|
||||
}
|
||||
|
||||
// Get a list of CTFontDescriptors for a font family.
|
||||
fn descriptors_for_family(name: &str) -> Option<CFArray<CTFontDescriptor>> {
|
||||
let attributes: CFDictionary<CFString, CFType> = CFDictionary::from_CFType_pairs(&[(
|
||||
CFString::new("NSFontFamilyAttribute"),
|
||||
CFString::new(name).as_CFType(),
|
||||
)]);
|
||||
|
||||
let descriptor = font_descriptor::new_from_attributes(&attributes);
|
||||
let collection_descriptors = &CFArray::from_CFTypes(&[descriptor]);
|
||||
let collection = font_collection::new_from_descriptors(collection_descriptors);
|
||||
|
||||
collection.get_descriptors()
|
||||
}
|
||||
|
||||
// Convert a CTFontDescriptor to font_id. This function does not load fallback fonts
|
||||
// and assumes the descriptor refers to a valid system font.
|
||||
fn descriptor_to_font_id(&self, fontdesc: ItemRef<CTFontDescriptor>) -> Option<FontId> {
|
||||
let name = match unsafe { FontDB::get_family_name(&fontdesc) } {
|
||||
Some(family) => family,
|
||||
None => {
|
||||
log::warn!("Failed to load the font as it does not have a valid family name.");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let font_name = match unsafe { FontDB::get_font_name(&fontdesc) } {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
log::warn!("Failed to load the font as it does not have a valid name.");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// We should not load fonts with name that starts with dot
|
||||
// https://developer.apple.com/videos/play/wwdc2019/227/?time=200
|
||||
(!name.starts_with('.')).then(|| {
|
||||
// Check if the fallback font is in cache.
|
||||
match self.fonts_by_name.entry(Arc::new(font_name)) {
|
||||
Entry::Occupied(entry) => return *entry.get(),
|
||||
Entry::Vacant(_) => (),
|
||||
}
|
||||
|
||||
// We need to push font after releasing the entry of the dashmap to prevent deadlocks.
|
||||
self.push_font(Font::from_ct_font(font::new_from_descriptor(
|
||||
&fontdesc,
|
||||
DEFAULT_FONT_SIZE,
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_font(&self, family_id: FamilyId, properties: Properties) -> FontId {
|
||||
match self.font_selections.entry((family_id, properties)) {
|
||||
Entry::Occupied(entry) => *entry.get(),
|
||||
Entry::Vacant(entry) => {
|
||||
let family = &self
|
||||
.families
|
||||
.get(&family_id)
|
||||
.expect("FamilyId must correspond to a valid family");
|
||||
let candidates = family
|
||||
.font_ids
|
||||
.iter()
|
||||
.map(|font_id| self.font(*font_id).properties())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let font_id = {
|
||||
if let Ok(idx) = font_kit::matching::find_best_match(
|
||||
&candidates,
|
||||
&properties_to_font_kit(properties),
|
||||
) {
|
||||
self.font(family.font_ids[idx]).properties();
|
||||
family.font_ids[idx]
|
||||
} else {
|
||||
font_kit::matching::find_best_match(&candidates, &Default::default())
|
||||
.map(|idx| family.font_ids[idx])
|
||||
.unwrap_or(family.font_ids[0])
|
||||
}
|
||||
};
|
||||
|
||||
// Make sure we've loaded fallback fonts for the selected font.
|
||||
if !self.fallback_fonts.contains_key(&font_id) {
|
||||
self.fallback_fonts.insert(
|
||||
font_id,
|
||||
Arc::new(self.cascade_list_for_languages(
|
||||
&self.font(font_id).native_font(),
|
||||
&["en"],
|
||||
)),
|
||||
);
|
||||
}
|
||||
*entry.insert(font_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn font(&self, font_id: FontId) -> Arc<Font> {
|
||||
self.rasterizer.font_for_id(font_id)
|
||||
}
|
||||
|
||||
pub fn native_font(&self, font_id: FontId, size: f32) -> NativeFont {
|
||||
match self.native_fonts.entry((font_id, OrderedFloat(size))) {
|
||||
Entry::Occupied(entry) => entry.get().clone(),
|
||||
Entry::Vacant(entry) => entry
|
||||
.insert(
|
||||
self.rasterizer
|
||||
.font_for_id(font_id)
|
||||
.native_font()
|
||||
.clone_with_font_size(size as f64),
|
||||
)
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the horizontal advance of a space character at the given size, or `None` if the
|
||||
/// advance could not be measured. Uses a cached reference advance (measured at
|
||||
/// `DEFAULT_FONT_SIZE`) scaled linearly to `size`.
|
||||
pub fn space_advance_width(&self, font_id: FontId, size: f32) -> Option<f64> {
|
||||
let stored = *self.space_advances.get(&font_id)?;
|
||||
stored.map(|a| a * size as f64 / DEFAULT_FONT_SIZE)
|
||||
}
|
||||
|
||||
pub fn font_id_for_native_font(&self, native_font: NativeFont) -> FontId {
|
||||
let postscript_name = native_font.postscript_name();
|
||||
if let Some(font_id) = self.fonts_by_name.get(&postscript_name).as_ref() {
|
||||
return *font_id.value();
|
||||
}
|
||||
|
||||
self.push_font(Font::from_ct_font(native_font))
|
||||
}
|
||||
|
||||
fn push_font(&self, font: Font) -> FontId {
|
||||
let name = Arc::new(font.postscript_name().unwrap());
|
||||
let font_id = FontId(self.next_font_id.fetch_add(1, Ordering::SeqCst));
|
||||
|
||||
let ct_font = font.native_font().clone_with_font_size(DEFAULT_FONT_SIZE);
|
||||
let advance = space_advance_width(&ct_font);
|
||||
|
||||
self.rasterizer.insert(font_id, Arc::new(font));
|
||||
self.font_names.insert(font_id, name.clone());
|
||||
self.fonts_by_name.insert(name, font_id);
|
||||
self.space_advances.insert(font_id, advance);
|
||||
font_id
|
||||
}
|
||||
|
||||
fn insert_font_family(&mut self, font_family: FontFamily) -> Result<FamilyId> {
|
||||
if let Some(family_id) = self.family_id_for_name(&font_family.name) {
|
||||
return Ok(family_id);
|
||||
}
|
||||
|
||||
let font_ids = font_family
|
||||
.fonts
|
||||
.into_iter()
|
||||
.map(|font| self.push_font(font));
|
||||
|
||||
let family_id = self.create_new_family_id();
|
||||
self.families.insert(
|
||||
family_id,
|
||||
Family {
|
||||
name: font_family.name,
|
||||
font_ids: font_ids.collect(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(family_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::platform::FontDB for FontDB {
|
||||
fn load_from_bytes(&mut self, name: &str, bytes: Vec<Vec<u8>>) -> Result<FamilyId> {
|
||||
let family = loader::load_font_family_from_bytes(name, bytes)?;
|
||||
self.insert_font_family(family)
|
||||
}
|
||||
|
||||
fn load_from_system(&mut self, font_family: &str) -> Result<FamilyId> {
|
||||
let family = loader::load_system_font(font_family)?;
|
||||
self.insert_font_family(family)
|
||||
}
|
||||
|
||||
fn load_all_system_fonts(&self) -> BoxFuture<'static, Box<dyn platform::LoadedSystemFonts>> {
|
||||
async { Box::new(loader::load_all_system_fonts()) as Box<dyn platform::LoadedSystemFonts> }
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn process_loaded_system_fonts(
|
||||
&mut self,
|
||||
loaded_system_fonts: Box<dyn platform::LoadedSystemFonts>,
|
||||
) -> Vec<(Option<FamilyId>, crate::fonts::FontInfo)> {
|
||||
let loaded_system_fonts: Box<LoadedSystemFonts> = loaded_system_fonts
|
||||
.as_any()
|
||||
.downcast()
|
||||
.expect("should not fail to downcast to concrete type");
|
||||
|
||||
loaded_system_fonts
|
||||
.0
|
||||
.into_iter()
|
||||
.flat_map(|(font_info, family)| {
|
||||
let family_id = self.insert_font_family(family).ok()?;
|
||||
Some((Some(family_id), font_info))
|
||||
})
|
||||
.collect_vec()
|
||||
}
|
||||
|
||||
fn fallback_fonts(&self, _ch: char, font_id: FontId) -> Vec<FontId> {
|
||||
self.fallback_fonts(font_id)
|
||||
}
|
||||
|
||||
fn load_family_name_from_id(&self, id: FamilyId) -> Option<String> {
|
||||
self.load_family_name_from_id(id)
|
||||
}
|
||||
|
||||
fn select_font(&self, family_id: FamilyId, properties: Properties) -> FontId {
|
||||
self.select_font(family_id, properties)
|
||||
}
|
||||
|
||||
fn font_metrics(&self, font_id: FontId) -> Metrics {
|
||||
match self.metrics.entry(font_id) {
|
||||
Entry::Occupied(entry) => *entry.get(),
|
||||
Entry::Vacant(entry) => *entry.insert(self.font(font_id).metrics().into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result<Vector2I> {
|
||||
Ok(self.font(font_id).advance(glyph_id)?.to_i32())
|
||||
}
|
||||
|
||||
fn glyph_raster_bounds(
|
||||
&self,
|
||||
font_id: FontId,
|
||||
point_size: f32,
|
||||
glyph_id: GlyphId,
|
||||
scale: Vector2F,
|
||||
glyph_config: &rendering::GlyphConfig,
|
||||
) -> Result<RectI> {
|
||||
self.rasterizer
|
||||
.glyph_raster_bounds(font_id, point_size, glyph_id, scale, glyph_config)
|
||||
}
|
||||
|
||||
fn glyph_typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result<RectI> {
|
||||
Ok(self.font(font_id).typographic_bounds(glyph_id)?.to_i32())
|
||||
}
|
||||
|
||||
fn rasterize_glyph(
|
||||
&self,
|
||||
font_id: FontId,
|
||||
point_size: f32,
|
||||
glyph_id: GlyphId,
|
||||
scale: Vector2F,
|
||||
subpixel_alignment: SubpixelAlignment,
|
||||
glyph_config: &rendering::GlyphConfig,
|
||||
format: RasterFormat,
|
||||
) -> Result<RasterizedGlyph> {
|
||||
self.rasterizer.rasterize_glyph(
|
||||
font_id,
|
||||
point_size,
|
||||
glyph_id,
|
||||
scale,
|
||||
subpixel_alignment,
|
||||
glyph_config,
|
||||
format,
|
||||
)
|
||||
}
|
||||
|
||||
fn glyph_for_char(&self, font: FontId, char: char) -> Option<GlyphId> {
|
||||
self.font(font).glyph_for_char(char)
|
||||
}
|
||||
|
||||
fn family_id_for_name(&self, name: &str) -> Option<FamilyId> {
|
||||
self.families
|
||||
.iter()
|
||||
.find(|(_, f)| f.name == name)
|
||||
.map(|(id, _)| *id)
|
||||
}
|
||||
|
||||
fn text_layout_system(&self) -> &dyn TextLayoutSystem {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::platform::TextLayoutSystem for FontDB {
|
||||
fn layout_line(
|
||||
&self,
|
||||
text: &str,
|
||||
line_style: LineStyle,
|
||||
style_runs: &[(Range<usize>, StyleAndFont)],
|
||||
_max_width: f32,
|
||||
clip_config: ClipConfig,
|
||||
) -> crate::text_layout::Line {
|
||||
layout_line(text, line_style, style_runs, self, clip_config)
|
||||
}
|
||||
|
||||
fn layout_text(
|
||||
&self,
|
||||
text: &str,
|
||||
line_style: LineStyle,
|
||||
style_runs: &[(Range<usize>, StyleAndFont)],
|
||||
max_width: f32,
|
||||
max_height: f32,
|
||||
alignment: TextAlignment,
|
||||
first_line_head_indent: Option<f32>,
|
||||
) -> TextFrame {
|
||||
layout_text(
|
||||
text,
|
||||
line_style,
|
||||
style_runs,
|
||||
self,
|
||||
max_width,
|
||||
max_height,
|
||||
alignment,
|
||||
first_line_head_indent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use cocoa::foundation::{NSPoint, NSRect, NSSize};
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
pub trait Vector2FExt {
|
||||
fn to_ns_point(&self) -> NSPoint;
|
||||
fn to_ns_size(&self) -> NSSize;
|
||||
}
|
||||
|
||||
pub trait RectFExt {
|
||||
fn to_ns_rect(&self) -> NSRect;
|
||||
}
|
||||
|
||||
impl Vector2FExt for Vector2F {
|
||||
fn to_ns_point(&self) -> NSPoint {
|
||||
NSPoint::new(self.x() as f64, self.y() as f64)
|
||||
}
|
||||
|
||||
fn to_ns_size(&self) -> NSSize {
|
||||
NSSize::new(self.x() as f64, self.y() as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl RectFExt for RectF {
|
||||
fn to_ns_rect(&self) -> NSRect {
|
||||
NSRect::new(self.origin().to_ns_point(), self.size().to_ns_size())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use std::slice;
|
||||
|
||||
use cocoa::{
|
||||
base::{id, nil, BOOL},
|
||||
foundation::{NSArray, NSString, NSUInteger},
|
||||
};
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
use galaxyui_core::keymap::Keystroke;
|
||||
use galaxyui_core::platform::keyboard::{KeyCode, NativeKeyCode, PhysicalKey};
|
||||
|
||||
use super::make_nsstring;
|
||||
|
||||
// Modifier key mask values for the Carbon API.
|
||||
pub const CMD_KEY: u16 = 256;
|
||||
pub const SHIFT_KEY: u16 = 512;
|
||||
pub const OPTION_KEY: u16 = 2048;
|
||||
pub const CONTROL_KEY: u16 = 4096;
|
||||
|
||||
extern "C" {
|
||||
fn charToKeyCodes(keyChar: id) -> id;
|
||||
fn keyCodeToChar(keyCode: NSUInteger, shifted: BOOL) -> id;
|
||||
}
|
||||
|
||||
pub struct Keycode(pub u16);
|
||||
|
||||
impl Keycode {
|
||||
pub fn try_to_key_name(self, shift_key_pressed: bool) -> Option<String> {
|
||||
unsafe {
|
||||
// The underlying core-foundation library interprets objc BOOL type as bool
|
||||
// in aarch machines but as i8 in intel machines so we need to call .into here.
|
||||
// But clippy isn't smart enough to know that so we silence it here for now.
|
||||
#[allow(clippy::useless_conversion)]
|
||||
let key = keyCodeToChar(self.0 as u64, shift_key_pressed.into());
|
||||
|
||||
if key == nil {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cstr = key.UTF8String() as *const u8;
|
||||
std::str::from_utf8(slice::from_raw_parts(cstr, key.len()))
|
||||
.ok()
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
(0..keycodes_length).map(move |i| {
|
||||
let keycode: NSUInteger =
|
||||
msg_send![keycodes.objectAtIndex(i), unsignedIntegerValue];
|
||||
Self(keycode as u16)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert modifier flags to Carbon style modifier key mask.
|
||||
pub fn modifier_code(keystroke: &Keystroke) -> u16 {
|
||||
let mut code = 0;
|
||||
if keystroke.alt {
|
||||
code |= OPTION_KEY;
|
||||
}
|
||||
|
||||
if keystroke.cmd {
|
||||
code |= CMD_KEY;
|
||||
}
|
||||
|
||||
if keystroke.shift {
|
||||
code |= SHIFT_KEY;
|
||||
}
|
||||
|
||||
if keystroke.ctrl {
|
||||
code |= CONTROL_KEY;
|
||||
}
|
||||
|
||||
code
|
||||
}
|
||||
|
||||
// The following types and functions are taken from winit's appkit implementation.
|
||||
// We redefine them here to avoid needing to include the entirety of winit as a dependency for MacOS.
|
||||
// --------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// Converts a scancode to a physical key. Logic is taken from winit appkit code.
|
||||
pub(crate) fn scancode_to_physicalkey(scancode: u32) -> PhysicalKey {
|
||||
// Follows what Chromium and Firefox do:
|
||||
// https://chromium.googlesource.com/chromium/src.git/+/3e1a26c44c024d97dc9a4c09bbc6a2365398ca2c/ui/events/keycodes/dom/dom_code_data.inc
|
||||
// https://searchfox.org/mozilla-central/rev/c597e9c789ad36af84a0370d395be066b7dc94f4/widget/NativeKeyToDOMCodeName.h
|
||||
//
|
||||
// See also:
|
||||
// Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Versions/A/Headers/Events.h
|
||||
//
|
||||
// Also see https://developer.apple.com/documentation/appkit/function-key-unicode-values:
|
||||
//
|
||||
// > the system handles some function keys at a lower level and your app never sees them.
|
||||
// > Examples include the Volume Up key, Volume Down key, Volume Mute key, Eject key, and
|
||||
// > Function key found on many Macs.
|
||||
//
|
||||
// So the handling of some of these is mostly for show.
|
||||
PhysicalKey::Code(match scancode {
|
||||
0x00 => KeyCode::KeyA,
|
||||
0x01 => KeyCode::KeyS,
|
||||
0x02 => KeyCode::KeyD,
|
||||
0x03 => KeyCode::KeyF,
|
||||
0x04 => KeyCode::KeyH,
|
||||
0x05 => KeyCode::KeyG,
|
||||
0x06 => KeyCode::KeyZ,
|
||||
0x07 => KeyCode::KeyX,
|
||||
0x08 => KeyCode::KeyC,
|
||||
0x09 => KeyCode::KeyV,
|
||||
// This key is typically located near LeftShift key, roughly the same location as backquote
|
||||
// (`) on Windows' US layout.
|
||||
//
|
||||
// The keycap varies on international keyboards.
|
||||
0x0a => KeyCode::IntlBackslash,
|
||||
0x0b => KeyCode::KeyB,
|
||||
0x0c => KeyCode::KeyQ,
|
||||
0x0d => KeyCode::KeyW,
|
||||
0x0e => KeyCode::KeyE,
|
||||
0x0f => KeyCode::KeyR,
|
||||
0x10 => KeyCode::KeyY,
|
||||
0x11 => KeyCode::KeyT,
|
||||
0x12 => KeyCode::Digit1,
|
||||
0x13 => KeyCode::Digit2,
|
||||
0x14 => KeyCode::Digit3,
|
||||
0x15 => KeyCode::Digit4,
|
||||
0x16 => KeyCode::Digit6,
|
||||
0x17 => KeyCode::Digit5,
|
||||
0x18 => KeyCode::Equal,
|
||||
0x19 => KeyCode::Digit9,
|
||||
0x1a => KeyCode::Digit7,
|
||||
0x1b => KeyCode::Minus,
|
||||
0x1c => KeyCode::Digit8,
|
||||
0x1d => KeyCode::Digit0,
|
||||
0x1e => KeyCode::BracketRight,
|
||||
0x1f => KeyCode::KeyO,
|
||||
0x20 => KeyCode::KeyU,
|
||||
0x21 => KeyCode::BracketLeft,
|
||||
0x22 => KeyCode::KeyI,
|
||||
0x23 => KeyCode::KeyP,
|
||||
0x24 => KeyCode::Enter,
|
||||
0x25 => KeyCode::KeyL,
|
||||
0x26 => KeyCode::KeyJ,
|
||||
0x27 => KeyCode::Quote,
|
||||
0x28 => KeyCode::KeyK,
|
||||
0x29 => KeyCode::Semicolon,
|
||||
0x2a => KeyCode::Backslash,
|
||||
0x2b => KeyCode::Comma,
|
||||
0x2c => KeyCode::Slash,
|
||||
0x2d => KeyCode::KeyN,
|
||||
0x2e => KeyCode::KeyM,
|
||||
0x2f => KeyCode::Period,
|
||||
0x30 => KeyCode::Tab,
|
||||
0x31 => KeyCode::Space,
|
||||
0x32 => KeyCode::Backquote,
|
||||
0x33 => KeyCode::Backspace,
|
||||
// 0x34 => unknown, // kVK_Powerbook_KeypadEnter
|
||||
0x35 => KeyCode::Escape,
|
||||
0x36 => KeyCode::SuperRight,
|
||||
0x37 => KeyCode::SuperLeft,
|
||||
0x38 => KeyCode::ShiftLeft,
|
||||
0x39 => KeyCode::CapsLock,
|
||||
0x3a => KeyCode::AltLeft,
|
||||
0x3b => KeyCode::ControlLeft,
|
||||
0x3c => KeyCode::ShiftRight,
|
||||
0x3d => KeyCode::AltRight,
|
||||
0x3e => KeyCode::ControlRight,
|
||||
0x3f => KeyCode::Fn,
|
||||
0x40 => KeyCode::F17,
|
||||
0x41 => KeyCode::NumpadDecimal,
|
||||
// 0x42 -> unknown,
|
||||
0x43 => KeyCode::NumpadMultiply,
|
||||
// 0x44 => unknown,
|
||||
0x45 => KeyCode::NumpadAdd,
|
||||
// 0x46 => unknown,
|
||||
0x47 => KeyCode::NumLock, // kVK_ANSI_KeypadClear
|
||||
0x48 => KeyCode::AudioVolumeUp,
|
||||
0x49 => KeyCode::AudioVolumeDown,
|
||||
0x4a => KeyCode::AudioVolumeMute,
|
||||
0x4b => KeyCode::NumpadDivide,
|
||||
0x4c => KeyCode::NumpadEnter,
|
||||
// 0x4d => unknown,
|
||||
0x4e => KeyCode::NumpadSubtract,
|
||||
0x4f => KeyCode::F18,
|
||||
0x50 => KeyCode::F19,
|
||||
0x51 => KeyCode::NumpadEqual,
|
||||
0x52 => KeyCode::Numpad0,
|
||||
0x53 => KeyCode::Numpad1,
|
||||
0x54 => KeyCode::Numpad2,
|
||||
0x55 => KeyCode::Numpad3,
|
||||
0x56 => KeyCode::Numpad4,
|
||||
0x57 => KeyCode::Numpad5,
|
||||
0x58 => KeyCode::Numpad6,
|
||||
0x59 => KeyCode::Numpad7,
|
||||
0x5a => KeyCode::F20,
|
||||
0x5b => KeyCode::Numpad8,
|
||||
0x5c => KeyCode::Numpad9,
|
||||
0x5d => KeyCode::IntlYen,
|
||||
0x5e => KeyCode::IntlRo,
|
||||
0x5f => KeyCode::NumpadComma,
|
||||
0x60 => KeyCode::F5,
|
||||
0x61 => KeyCode::F6,
|
||||
0x62 => KeyCode::F7,
|
||||
0x63 => KeyCode::F3,
|
||||
0x64 => KeyCode::F8,
|
||||
0x65 => KeyCode::F9,
|
||||
0x66 => KeyCode::Lang2,
|
||||
0x67 => KeyCode::F11,
|
||||
0x68 => KeyCode::Lang1,
|
||||
0x69 => KeyCode::F13,
|
||||
0x6a => KeyCode::F16,
|
||||
0x6b => KeyCode::F14,
|
||||
// 0x6c => unknown,
|
||||
0x6d => KeyCode::F10,
|
||||
0x6e => KeyCode::ContextMenu,
|
||||
0x6f => KeyCode::F12,
|
||||
// 0x70 => unknown,
|
||||
0x71 => KeyCode::F15,
|
||||
0x72 => KeyCode::Insert,
|
||||
0x73 => KeyCode::Home,
|
||||
0x74 => KeyCode::PageUp,
|
||||
0x75 => KeyCode::Delete,
|
||||
0x76 => KeyCode::F4,
|
||||
0x77 => KeyCode::End,
|
||||
0x78 => KeyCode::F2,
|
||||
0x79 => KeyCode::PageDown,
|
||||
0x7a => KeyCode::F1,
|
||||
0x7b => KeyCode::ArrowLeft,
|
||||
0x7c => KeyCode::ArrowRight,
|
||||
0x7d => KeyCode::ArrowDown,
|
||||
0x7e => KeyCode::ArrowUp,
|
||||
0x7f => KeyCode::Power, // On 10.7 and 10.8 only
|
||||
_ => return PhysicalKey::Unidentified(NativeKeyCode::MacOS(scancode as u16)),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
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 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 galaxyui_core::actions::StandardAction;
|
||||
use galaxyui_core::keymap::Keystroke;
|
||||
use galaxyui_core::platform::menu::{
|
||||
ItemTriggeredCallback, Menu, MenuBar, MenuItem, MenuItemProperties, MenuItemPropertyChanges,
|
||||
UpdateMenuItemCallback,
|
||||
};
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
HashMap::from([
|
||||
("up", to_char(NSUpArrowFunctionKey)),
|
||||
("down", to_char(NSDownArrowFunctionKey)),
|
||||
("left", to_char(NSLeftArrowFunctionKey)),
|
||||
("right", to_char(NSRightArrowFunctionKey)),
|
||||
("home", to_char(NSHomeFunctionKey)),
|
||||
("end", to_char(NSEndFunctionKey)),
|
||||
("pageup", to_char(NSPageUpFunctionKey)),
|
||||
("pagedown", to_char(NSPageDownFunctionKey)),
|
||||
("enter", '\n'),
|
||||
("tab", '\t'),
|
||||
("insert", to_char(NSInsertFunctionKey)),
|
||||
("f1", to_char(NSF1FunctionKey)),
|
||||
("f2", to_char(NSF2FunctionKey)),
|
||||
("f3", to_char(NSF3FunctionKey)),
|
||||
("f4", to_char(NSF4FunctionKey)),
|
||||
("f5", to_char(NSF5FunctionKey)),
|
||||
("f6", to_char(NSF6FunctionKey)),
|
||||
("f7", to_char(NSF7FunctionKey)),
|
||||
("f8", to_char(NSF8FunctionKey)),
|
||||
("f9", to_char(NSF9FunctionKey)),
|
||||
("f10", to_char(NSF10FunctionKey)),
|
||||
("f11", to_char(NSF11FunctionKey)),
|
||||
("f12", to_char(NSF12FunctionKey)),
|
||||
("f13", to_char(NSF13FunctionKey)),
|
||||
("f14", to_char(NSF14FunctionKey)),
|
||||
("f15", to_char(NSF15FunctionKey)),
|
||||
("f16", to_char(NSF16FunctionKey)),
|
||||
("f17", to_char(NSF17FunctionKey)),
|
||||
("f18", to_char(NSF18FunctionKey)),
|
||||
("f19", to_char(NSF19FunctionKey)),
|
||||
("f20", to_char(NSF20FunctionKey)),
|
||||
// The following values are the inverse of `ui/src/platform/mac/event.rs` mappings
|
||||
("numpadenter", to_char(0x03)),
|
||||
("escape", to_char(0x1b)),
|
||||
// Note: Backspace and Delete have different characters for the menu key equivalents
|
||||
// than they send when they are pressed. See the discussion in the Apple docs:
|
||||
// https://developer.apple.com/documentation/appkit/nsmenuitem/1514842-keyequivalent?language=objc
|
||||
("backspace", to_char(0x08)),
|
||||
("delete", to_char(0x7F)),
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
/// Data associated with a custom NSMenuItem.
|
||||
struct MenuItemData {
|
||||
/// Properties of the menu item.
|
||||
/// These could be computed from the menu item but we trust AppKit does not change them.
|
||||
props: RefCell<MenuItemProperties>,
|
||||
|
||||
/// Callback when the menu item is triggered by the user.
|
||||
triggered: ItemTriggeredCallback,
|
||||
|
||||
/// Callback when the menu item needs updating.
|
||||
update: UpdateMenuItemCallback,
|
||||
}
|
||||
|
||||
impl MenuItemData {
|
||||
/// Convert self to a Cocoa context pointer, including the refcount.
|
||||
/// This should be balanced by consume_cocoa_context.
|
||||
fn into_context(self: Rc<MenuItemData>) -> *mut c_void {
|
||||
Box::into_raw(Box::new(self)) as *mut c_void
|
||||
}
|
||||
|
||||
/// Read out from the Cocoa context pointer, without consuming its refcount.
|
||||
fn read_context(ctx: *const c_void) -> Rc<MenuItemData> {
|
||||
unsafe {
|
||||
let ptr = &*(ctx as *const Rc<MenuItemData>);
|
||||
ptr.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Balances a call from to_cocoa_context.
|
||||
fn consume_context(ctx: *mut c_void) {
|
||||
unsafe { std::mem::drop(Box::from_raw(ctx as *mut Rc<MenuItemData>)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// We hand Cocoa a void* which is really an unwrapped Box<Rc<MenuItemData>>.
|
||||
/// The NSMenuItem logically holds a reference count on this Rc, which is balanced in our dealloc callback below.
|
||||
/// The following functions are invoked from Cocoa.
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_menu_item_needs_update(item: id, ctx: *mut c_void) {
|
||||
let ctx = MenuItemData::read_context(ctx);
|
||||
let props: MenuItemProperties = ctx.props.borrow().clone();
|
||||
let func = &ctx.update;
|
||||
|
||||
let mut updated_properties = callback_dispatcher().update_menu_item(|ctx| func(&props, ctx));
|
||||
|
||||
// Always re-apply the disabled state even when the updater has no opinion.
|
||||
// AppKit's modal sessions (e.g. [NSAlert runModal]) can externally disable
|
||||
// menu items, and items whose updaters return `disabled: None` would never
|
||||
// call setEnabled: to restore the correct state. On macOS with the quake
|
||||
// mode (non-activating panel) window, this results in permanently disabled
|
||||
// items after a modal is dismissed. Default to enabled — updaters that want
|
||||
// an item disabled must say so explicitly.
|
||||
if updated_properties.disabled.is_none() {
|
||||
updated_properties.disabled = Some(false);
|
||||
}
|
||||
|
||||
// Update any changed properties.
|
||||
ctx.props.borrow_mut().apply(&updated_properties);
|
||||
unsafe { apply_changes(updated_properties, item) };
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_menu_item_triggered(_item: id, ctx: *mut c_void) {
|
||||
let func = &MenuItemData::read_context(ctx).triggered;
|
||||
callback_dispatcher().menu_item_triggered(func);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
extern "C-unwind" fn warp_menu_item_deallocated(ctx: *mut c_void) {
|
||||
MenuItemData::consume_context(ctx)
|
||||
}
|
||||
|
||||
// Declarations of functions implemented in ObjC files.
|
||||
// These signatures must be manually synced - there's no type checking here.
|
||||
extern "C" {
|
||||
fn make_delegated_menu(title: id) -> id;
|
||||
fn make_warp_custom_menu_item(ctx: *mut c_void) -> id;
|
||||
fn set_menu_item_submenu(item: id, submenu: id);
|
||||
fn make_services_menu_item() -> id;
|
||||
}
|
||||
|
||||
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
|
||||
modifiers: NSEventModifierFlags,
|
||||
}
|
||||
|
||||
// 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 none = NSEventModifierFlags::empty();
|
||||
|
||||
fn make(
|
||||
title: &'static str,
|
||||
action: &'static str,
|
||||
modifiers: NSEventModifierFlags,
|
||||
shortcut: &'static str,
|
||||
) -> StandardMenuItemProperties {
|
||||
StandardMenuItemProperties {
|
||||
title,
|
||||
action,
|
||||
shortcut,
|
||||
modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
match action {
|
||||
StandardAction::Close => make("Close Window", "performClose:", none, ""),
|
||||
StandardAction::Quit => make("Quit Warp", "terminate:", cmd, "q"),
|
||||
StandardAction::Hide => make("Hide Warp", "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, ""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine the key equivalent for the given keystroke
|
||||
fn resolve_key_equivalent(keystroke: Option<&Keystroke>) -> (id, NSEventModifierFlags) {
|
||||
let mut flags = NSEventModifierFlags::empty();
|
||||
|
||||
let keystroke = match keystroke {
|
||||
Some(value) => value,
|
||||
None => return (make_nsstring(""), 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),
|
||||
};
|
||||
|
||||
for (is_set, flag) in [
|
||||
(keystroke.cmd, NSEventModifierFlags::NSCommandKeyMask),
|
||||
(keystroke.alt, NSEventModifierFlags::NSAlternateKeyMask),
|
||||
(keystroke.shift, NSEventModifierFlags::NSShiftKeyMask),
|
||||
(keystroke.ctrl, NSEventModifierFlags::NSControlKeyMask),
|
||||
] {
|
||||
if is_set {
|
||||
flags |= flag
|
||||
}
|
||||
}
|
||||
|
||||
(key_equivalent, flags)
|
||||
}
|
||||
|
||||
// Apply any differences between the two states to the menu item.
|
||||
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();
|
||||
}
|
||||
|
||||
unsafe fn make_submenu(menu_items: Vec<MenuItem>) -> id {
|
||||
let nsmenu = make_delegated_menu(make_nsstring(""));
|
||||
for menu_item in menu_items {
|
||||
nsmenu.addItem_(make_menu_item(menu_item));
|
||||
}
|
||||
nsmenu
|
||||
}
|
||||
|
||||
unsafe fn make_menu_item(menu_item: MenuItem) -> id {
|
||||
match menu_item {
|
||||
MenuItem::Custom(custom_menu_item) => {
|
||||
let props = custom_menu_item.properties;
|
||||
let data = Rc::new(MenuItemData {
|
||||
props: RefCell::new(props.clone()),
|
||||
triggered: custom_menu_item.callback,
|
||||
update: custom_menu_item.updater,
|
||||
});
|
||||
|
||||
let nsmenu_item = make_warp_custom_menu_item(MenuItemData::into_context(data));
|
||||
|
||||
// Set initial properties for the item.
|
||||
apply_changes(
|
||||
MenuItemPropertyChanges::for_new_item(props, custom_menu_item.submenu),
|
||||
nsmenu_item,
|
||||
);
|
||||
|
||||
nsmenu_item
|
||||
}
|
||||
MenuItem::Standard(standard_action) => {
|
||||
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
|
||||
}
|
||||
MenuItem::Separator => NSMenuItem::separatorItem(nil),
|
||||
MenuItem::Services => make_services_menu_item(),
|
||||
}
|
||||
}
|
||||
|
||||
/// \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));
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
for menu_item in menu.menu_items {
|
||||
nsmenu.addItem_(make_menu_item(menu_item));
|
||||
}
|
||||
|
||||
let menuitem = NSMenuItem::alloc(nil).init().autorelease();
|
||||
menuitem.setSubmenu_(nsmenu);
|
||||
menuitem
|
||||
}
|
||||
|
||||
/// \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();
|
||||
for menu in menubar.menus {
|
||||
main_menu.addItem_(make_top_level_menu_item(menu));
|
||||
}
|
||||
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();
|
||||
for item in menu.menu_items {
|
||||
dock_menu.addItem_(make_menu_item(item));
|
||||
}
|
||||
dock_menu
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "menus_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Memory-behavior repros for APP-4154 batch 1.C (galaxyui-platform-nsstring).
|
||||
//!
|
||||
//! Covers two kinds of fix in `menus.rs`:
|
||||
//!
|
||||
//! 1. Retain → autorelease conversion (line 298, `make_menu_item` standard
|
||||
//! action): the key-equivalent NSString used to be `NSString::alloc(nil)
|
||||
//! .init_str(...)`, which returns a +1 retained reference. The PR uses
|
||||
//! `make_nsstring`, which autoreleases. Covered by
|
||||
//! [`make_menu_item_standard_action_memory_behavior`].
|
||||
//!
|
||||
//! 2. Local `NSAutoreleasePool` wrapper around `apply_changes` body. The
|
||||
//! NSString temporaries produced by `make_nsstring(name)` and inside
|
||||
//! `resolve_key_equivalent` used to go into whatever ambient pool AppKit
|
||||
//! had set up (or leak if called from a Rust thread with no active pool).
|
||||
//! The PR drains them per call. Covered by
|
||||
//! [`apply_changes_local_pool_memory_behavior`], which deliberately runs
|
||||
//! WITHOUT any outer `NSAutoreleasePool` so the local-pool drain is the
|
||||
//! only thing that can release the temporaries.
|
||||
use cocoa::appkit::NSMenuItem;
|
||||
use cocoa::base::nil;
|
||||
use cocoa::foundation::NSAutoreleasePool;
|
||||
use objc::runtime::Object;
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
use galaxyui_core::actions::StandardAction;
|
||||
use galaxyui_core::keymap::Keystroke;
|
||||
use galaxyui_core::platform::menu::{MenuItem, MenuItemPropertyChanges};
|
||||
|
||||
use super::{apply_changes, make_menu_item};
|
||||
|
||||
/// How many outer pool cycles for the retain → autorelease test.
|
||||
const MENU_ITEM_OUTER: usize = 40;
|
||||
/// Inner iterations per outer cycle. Each one allocates one NSMenuItem plus
|
||||
/// (on master) one retained NSString for the key equivalent.
|
||||
const MENU_ITEM_INNER: usize = 10_000;
|
||||
|
||||
/// Driver for the local-pool wrapper test. `apply_changes` creates a handful
|
||||
/// of NSString temporaries per call; without an outer pool, master accumulates
|
||||
/// them all, while the branch drains them per iteration.
|
||||
const APPLY_CHANGES_ITERS: usize = 200_000;
|
||||
|
||||
/// Reproduces the per-call NSString leak fixed by switching the key-equivalent
|
||||
/// argument to `make_nsstring` on line 298. Each outer cycle gets its own
|
||||
/// autorelease pool; the branch reclaims everything on drain, master keeps the
|
||||
/// retained key-equivalent strings alive.
|
||||
#[test]
|
||||
fn make_menu_item_standard_action_memory_behavior() {
|
||||
unsafe {
|
||||
for _ in 0..MENU_ITEM_OUTER {
|
||||
let pool = NSAutoreleasePool::new(nil);
|
||||
for _ in 0..MENU_ITEM_INNER {
|
||||
// `Quit` has a non-empty key equivalent ("q"); `Close Window`
|
||||
// has an empty one. Mix the two so we cover both branches.
|
||||
let _ = make_menu_item(MenuItem::Standard(StandardAction::Quit));
|
||||
let _ = make_menu_item(MenuItem::Standard(StandardAction::Close));
|
||||
}
|
||||
pool.drain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reproduces the accumulation that the `apply_changes` local pool prevents.
|
||||
/// Note the deliberate absence of an outer `NSAutoreleasePool` — this is what
|
||||
/// makes the local-pool wrapper observable.
|
||||
#[test]
|
||||
fn apply_changes_local_pool_memory_behavior() {
|
||||
unsafe {
|
||||
// Hold a single menu item for the entire loop so that the only growth
|
||||
// we measure is the NSString temporaries inside `apply_changes`, not
|
||||
// the menu item objects themselves.
|
||||
let outer_pool = NSAutoreleasePool::new(nil);
|
||||
let item: *mut Object = msg_send![NSMenuItem::alloc(nil), init];
|
||||
// Retain so we can freely drain the outer pool after constructing it.
|
||||
let _: *mut Object = msg_send![item, retain];
|
||||
outer_pool.drain();
|
||||
|
||||
for _ in 0..APPLY_CHANGES_ITERS {
|
||||
let changes = MenuItemPropertyChanges {
|
||||
name: Some("Warp Menu Item".to_string()),
|
||||
keystroke: Some(Some(Keystroke {
|
||||
cmd: true,
|
||||
key: "k".to_string(),
|
||||
..Default::default()
|
||||
})),
|
||||
disabled: Some(false),
|
||||
checked: Some(false),
|
||||
submenu: None,
|
||||
};
|
||||
apply_changes(changes, item);
|
||||
}
|
||||
|
||||
// Balance the manual retain above.
|
||||
let _: () = msg_send![item, release];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#![allow(deprecated)]
|
||||
|
||||
mod app;
|
||||
pub mod clipboard;
|
||||
pub mod delegate;
|
||||
mod event;
|
||||
pub(crate) mod fonts;
|
||||
mod geometry;
|
||||
mod keycode;
|
||||
mod menus;
|
||||
mod notification;
|
||||
pub(super) mod rendering;
|
||||
mod text_layout;
|
||||
pub mod utils;
|
||||
mod window;
|
||||
|
||||
pub use app::{App, AppExt};
|
||||
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};
|
||||
|
||||
/// 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() }
|
||||
}
|
||||
|
||||
/// Holds a Cocoa autorelease pool and drains it when the guard is dropped.
|
||||
///
|
||||
/// Many Cocoa APIs temporarily hold on to objects that only get freed when an
|
||||
/// enclosing autorelease pool is drained. AppKit's main event loop and GCD
|
||||
/// blocks create one of these pools around each callback, so most code doesn't
|
||||
/// have to think about it. But code that runs during app startup, on a thread
|
||||
/// Rust created itself, or in a tight loop inside a single event can't rely on
|
||||
/// the outer pool: objects accumulate in memory until that outer pool drains,
|
||||
/// which can be a long time.
|
||||
///
|
||||
/// Create a `AutoreleasePoolGuard` in that scope to open your own pool. The
|
||||
/// guard drains the pool automatically when it goes out of scope, whether the
|
||||
/// function returns normally, returns early via `?`, or unwinds due to a
|
||||
/// panic.
|
||||
pub struct AutoreleasePoolGuard(id);
|
||||
|
||||
impl AutoreleasePoolGuard {
|
||||
/// Creates a fresh `NSAutoreleasePool` whose lifetime is tied to the guard.
|
||||
pub fn new() -> Self {
|
||||
// SAFETY: `NSAutoreleasePool::new` is infallible and produces a pool
|
||||
// that is valid for the current thread until the guard drains it on
|
||||
// `Drop`.
|
||||
Self(unsafe { NSAutoreleasePool::new(nil) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoreleasePoolGuard {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AutoreleasePoolGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.0` was produced by `NSAutoreleasePool::new` in
|
||||
// `Self::new` and is drained at most once here.
|
||||
unsafe {
|
||||
let _: () = msg_send![self.0, drain];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::DateTime;
|
||||
use cocoa::base::id;
|
||||
use cocoa::foundation::NSUInteger;
|
||||
use galaxyui_core::notification::{
|
||||
NotificationResponse, NotificationSendError, RequestPermissionsOutcome,
|
||||
};
|
||||
|
||||
use super::utils::nsstring_as_str;
|
||||
|
||||
/// Build a Notification Response from a native notification event
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The `data` parameter must be a valid pointer to Objective-C string data
|
||||
pub unsafe fn response_from_native(
|
||||
seconds_from_epoch: i32,
|
||||
data: id,
|
||||
) -> Result<NotificationResponse> {
|
||||
let data = nsstring_as_str(data)?;
|
||||
|
||||
// Only set the data if it's not an empty string.
|
||||
let data = (!data.is_empty()).then_some(data);
|
||||
|
||||
let timestamp = DateTime::from_timestamp(seconds_from_epoch as i64, 0)
|
||||
.ok_or_else(|| anyhow!("failed to convert time"))?;
|
||||
Ok(NotificationResponse::new(
|
||||
timestamp.naive_utc(),
|
||||
data.map(Into::into),
|
||||
))
|
||||
}
|
||||
|
||||
/// Build a Notification send error from a native notification event
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The `error_message` parameter must be a valid pointer to Objective-C string data
|
||||
pub unsafe fn send_error_from_native(
|
||||
error_type: NSUInteger,
|
||||
error_message: id,
|
||||
) -> Result<NotificationSendError> {
|
||||
let error_message = nsstring_as_str(error_message)?.to_owned();
|
||||
|
||||
Ok(match error_type {
|
||||
0 => NotificationSendError::PermissionsDenied,
|
||||
1 => NotificationSendError::Other { error_message },
|
||||
_ => NotificationSendError::Other { error_message },
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a Notification request permissions outcome from a native notification event
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The `outcome_message` parameter must be a valid pointer to Objective-C string data
|
||||
pub unsafe fn request_permissions_outcome_from_native(
|
||||
outcome_type: NSUInteger,
|
||||
outcome_message: id,
|
||||
) -> Result<RequestPermissionsOutcome> {
|
||||
let outcome_message = nsstring_as_str(outcome_message)?.to_owned();
|
||||
|
||||
Ok(match outcome_type {
|
||||
0 => RequestPermissionsOutcome::Accepted,
|
||||
1 => RequestPermissionsOutcome::PermissionsDenied,
|
||||
2 => RequestPermissionsOutcome::OtherError {
|
||||
error_message: outcome_message,
|
||||
},
|
||||
_ => RequestPermissionsOutcome::OtherError {
|
||||
error_message: outcome_message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
BasedOnStyle: Google
|
||||
IndentWidth: 4
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
NSModalResponse configureAndRunModal(NSAlert* alert, NSApplication* app);
|
||||
@@ -0,0 +1,16 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
#import "app.h"
|
||||
|
||||
NSModalResponse configureAndRunModal(NSAlert *alert, NSApplication *app) {
|
||||
alert.showsSuppressionButton = YES;
|
||||
|
||||
// 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 an-
|
||||
// other app's window.
|
||||
[app activateIgnoringOtherApps:YES];
|
||||
NSModalResponse response = [alert runModal];
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <Carbon/Carbon.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
|
||||
// Our NSApplication subclass.
|
||||
@interface WarpApplication : NSApplication
|
||||
@end
|
||||
|
||||
// WarpDelegate is the delegate of the NSApp and also all menus.
|
||||
@interface WarpDelegate
|
||||
: NSObject <NSApplicationDelegate, NSMenuDelegate, UNUserNotificationCenterDelegate>
|
||||
|
||||
@property(strong) NSMenu *dockMenu;
|
||||
|
||||
@end
|
||||
|
||||
// Functions implemented in Rust.
|
||||
void warp_app_will_finish_launching(id app);
|
||||
void warp_app_did_become_active(id app);
|
||||
void warp_app_did_resign_active(id app);
|
||||
void warp_app_will_terminate(id app);
|
||||
void warp_app_open_files(id app, id filenames);
|
||||
void warp_app_send_global_keybinding(id app, NSUInteger modifiers, NSUInteger key_code);
|
||||
void warp_app_new_window(id app);
|
||||
void warp_app_window_did_resize(id app);
|
||||
void warp_app_window_did_move(id app);
|
||||
void warp_app_window_will_close(id app, id window);
|
||||
void warp_app_screen_did_change(id app);
|
||||
void cpu_awakened(id app);
|
||||
void cpu_will_sleep(id app);
|
||||
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_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);
|
||||
BOOL warp_app_has_custom_action_for_keystroke(id app, id event);
|
||||
void warp_app_disable_warning_modal(id app);
|
||||
void warp_app_internet_reachability_changed(id app, BOOL can_reach);
|
||||
void warp_app_process_modal_response(id app, NSUInteger modal_id, NSModalResponse response,
|
||||
BOOL disable_modal);
|
||||
@@ -0,0 +1,552 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <Carbon/Carbon.h>
|
||||
#import <ServiceManagement/ServiceManagement.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
|
||||
#import "alert.h"
|
||||
#import "app.h"
|
||||
#import "host_view.h"
|
||||
#import "hotkey.h"
|
||||
#import "menus.h"
|
||||
|
||||
#import "reachability.h"
|
||||
|
||||
static void *NSAppThemeChangeContext = &NSAppThemeChangeContext;
|
||||
|
||||
NSMutableDictionary<NSNumber *, WarpHotKey *> *_hotKeys;
|
||||
UInt32 _nextHotKeyID;
|
||||
|
||||
OSStatus HotkeyPressedHandler(EventHandlerCallRef _inCaller __unused, EventRef inEvent,
|
||||
void *inUserData);
|
||||
OSStatus HotkeyPressedHandler(EventHandlerCallRef _inCaller __unused, EventRef inEvent,
|
||||
void *inUserData) {
|
||||
EventHotKeyID hotKeyID;
|
||||
|
||||
// Get the hotKeyID corresponding to the pressed hot key.
|
||||
if (GetEventParameter(inEvent, kEventParamDirectObject, typeEventHotKeyID, nil,
|
||||
sizeof(EventHotKeyID), nil, &hotKeyID)) {
|
||||
return eventNotHandledErr;
|
||||
}
|
||||
|
||||
WarpHotKey *hotkey = _hotKeys[@(hotKeyID.id)];
|
||||
if (hotkey) {
|
||||
warp_app_send_global_keybinding((NSApplication *)inUserData, hotkey->_modifierKeys,
|
||||
hotkey->_keyCode);
|
||||
return noErr;
|
||||
}
|
||||
|
||||
return eventNotHandledErr;
|
||||
}
|
||||
|
||||
BOOL isDarkMode() {
|
||||
NSAppearanceName name = [NSApp.effectiveAppearance
|
||||
bestMatchFromAppearancesWithNames:@[ NSAppearanceNameAqua, NSAppearanceNameDarkAqua ]];
|
||||
return name == NSAppearanceNameDarkAqua;
|
||||
}
|
||||
|
||||
NSArray *getFilePathsFromPasteboard() {
|
||||
NSPasteboard *pb = [NSPasteboard generalPasteboard];
|
||||
NSArray *types = [pb types];
|
||||
|
||||
if ([types containsObject:NSPasteboardTypeFileURL]) {
|
||||
return [pb getFilePaths];
|
||||
}
|
||||
|
||||
return [NSArray array];
|
||||
}
|
||||
|
||||
void *registerGlobalHotkey(NSUInteger key, NSUInteger modifiers) {
|
||||
EventHotKeyRef hotKeyRef = NULL;
|
||||
EventHotKeyID hotKeyID = {0, _nextHotKeyID};
|
||||
if (RegisterEventHotKey((UInt32)key, (UInt32)modifiers, hotKeyID, GetEventDispatcherTarget(), 0,
|
||||
&hotKeyRef)) {
|
||||
return nil;
|
||||
};
|
||||
[_hotKeys setObject:[[[WarpHotKey alloc] initWithEventHotKey:hotKeyRef
|
||||
keyCode:key
|
||||
modifierKeys:modifiers] autorelease]
|
||||
forKey:@(hotKeyID.id)];
|
||||
_nextHotKeyID++;
|
||||
return nil;
|
||||
}
|
||||
|
||||
void *unregisterGlobalHotkey(NSUInteger key, NSUInteger modifiers) {
|
||||
NSNumber *keyIdx;
|
||||
BOOL found = NO;
|
||||
|
||||
for (NSNumber *hotKeyID in _hotKeys) {
|
||||
if ([[_hotKeys objectForKey:hotKeyID] hotKeyKeyAndModifierEquals:key
|
||||
modifierKeys:modifiers]) {
|
||||
keyIdx = hotKeyID;
|
||||
found = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found) {
|
||||
UnregisterEventHotKey([_hotKeys objectForKey:keyIdx]->_eventHotKey);
|
||||
[_hotKeys removeObjectForKey:keyIdx];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSRect screenFrame() { return [[NSScreen mainScreen] frame]; }
|
||||
|
||||
NSUInteger activeScreenId() {
|
||||
return [[[[NSScreen mainScreen] deviceDescription] objectForKey:@"NSScreenNumber"]
|
||||
unsignedIntegerValue];
|
||||
}
|
||||
|
||||
@interface WarpMenuItemDelegate : NSObject <NSMenuDelegate> {
|
||||
// Rust expects an ivar with this name.
|
||||
void *rustWrapper;
|
||||
}
|
||||
@end
|
||||
|
||||
@implementation WarpDelegate {
|
||||
// Rust expects an ivar with this name.
|
||||
void *rustWrapper;
|
||||
|
||||
// Whether we have a pending active window change notification.
|
||||
BOOL hasPendingActiveWindowChange;
|
||||
|
||||
// Internet reachability.
|
||||
Reachability *internetReachable;
|
||||
|
||||
// Track the current reachable state so we don't double fire reachability state
|
||||
// changed events.
|
||||
NSNumber *isReachable;
|
||||
|
||||
// Whether we should force termination.
|
||||
BOOL forceTermination;
|
||||
|
||||
// Whether we should terminate the application upon the app
|
||||
// being hidden. This allows us to hide the app before running any
|
||||
// slower termination logic.
|
||||
BOOL terminateOnHide;
|
||||
}
|
||||
|
||||
- (id)init {
|
||||
[super init];
|
||||
NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter];
|
||||
[defaultCenter addObserver:self
|
||||
selector:@selector(keyWindowChanged:)
|
||||
name:NSWindowDidBecomeKeyNotification
|
||||
object:nil];
|
||||
[defaultCenter addObserver:self
|
||||
selector:@selector(keyWindowChanged:)
|
||||
name:NSWindowDidResignKeyNotification
|
||||
object:nil];
|
||||
|
||||
[defaultCenter addObserver:self
|
||||
selector:@selector(windowMoved:)
|
||||
name:NSWindowDidMoveNotification
|
||||
object:nil];
|
||||
[defaultCenter addObserver:self
|
||||
selector:@selector(windowResized:)
|
||||
name:NSWindowDidResizeNotification
|
||||
object:nil];
|
||||
[defaultCenter addObserver:self
|
||||
selector:@selector(screenChanged:)
|
||||
name:NSApplicationDidChangeScreenParametersNotification
|
||||
object:nil];
|
||||
|
||||
// For the following notifications, we need to register them on the workspace
|
||||
// notification center, which is different from the default NSNotificationCenter.
|
||||
// See here for more details: https://developer.apple.com/library/archive/qa/qa1340/_index.html.
|
||||
NSNotificationCenter *workspaceCenter = [[NSWorkspace sharedWorkspace] notificationCenter];
|
||||
[workspaceCenter addObserver:self
|
||||
selector:@selector(cpuAwakened:)
|
||||
name:NSWorkspaceDidWakeNotification
|
||||
object:nil];
|
||||
[workspaceCenter addObserver:self
|
||||
selector:@selector(cpuWillSleep:)
|
||||
name:NSWorkspaceWillSleepNotification
|
||||
object:nil];
|
||||
|
||||
// Tell the shared notification center to use the current view as the
|
||||
// `UNUserNotificationCenterDelegate` delegate. We only do this if the application
|
||||
// is bundled, otherwise the app will crash when trying to set the delegate. This allows
|
||||
// warpui to still be run via `cargo run` since the app is not bundled in this case. Note this
|
||||
// has no functional change in the non-bundled case since the app must be bundled for
|
||||
// notifications to actually be sent/received.
|
||||
NSString *bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier];
|
||||
if (bundleIdentifier != nil && ![bundleIdentifier isEqualToString:(@"")]) {
|
||||
UNUserNotificationCenter *user_notification_center =
|
||||
[UNUserNotificationCenter currentNotificationCenter];
|
||||
user_notification_center.delegate = self;
|
||||
|
||||
// Create and register the notification category.
|
||||
UNNotificationCategory *CustomizedNotification = [UNNotificationCategory
|
||||
categoryWithIdentifier:@"CUSTOMIZED_NOTIFICATION"
|
||||
actions:@[]
|
||||
intentIdentifiers:@[]
|
||||
options:UNNotificationCategoryOptionCustomDismissAction];
|
||||
|
||||
[user_notification_center
|
||||
setNotificationCategories:[NSSet setWithObjects:CustomizedNotification, nil]];
|
||||
}
|
||||
|
||||
// Initiate the global hotkey handlers first so we could register them at the rust
|
||||
// side callback.
|
||||
EventTypeSpec eventType = {kEventClassKeyboard, kEventHotKeyPressed};
|
||||
InstallApplicationEventHandler(HotkeyPressedHandler, 1, &eventType, self, NULL);
|
||||
_hotKeys = [[NSMutableDictionary alloc] init];
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[NSApp removeObserver:self forKeyPath:@"effectiveAppearance" context:NSAppThemeChangeContext];
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
[internetReachable stopNotifier];
|
||||
[internetReachable release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
- (void)applicationWillFinishLaunching:(NSNotification *)note {
|
||||
// On macOS 26, the autofill heuristic controller causes significant slowdowns.
|
||||
// It's not clear why, but other apps which use custom text inputs have reported
|
||||
// the same issue. See:
|
||||
// * Ghostty: https://github.com/ghostty-org/ghostty/pull/8625
|
||||
// * Zed: https://github.com/zed-industries/zed/issues/33182
|
||||
// * Twitter thread discussing the issue: https://x.com/mitchellh/status/1967324131801915875
|
||||
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
||||
[defaults setBool:NO forKey:@"NSAutoFillHeuristicControllerEnabled"];
|
||||
|
||||
if (rustWrapper) warp_app_will_finish_launching(note.object);
|
||||
}
|
||||
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)note {
|
||||
[NSApp addObserver:self
|
||||
forKeyPath:@"effectiveAppearance"
|
||||
options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
|
||||
context:NSAppThemeChangeContext];
|
||||
}
|
||||
|
||||
- (void)observeValueForKeyPath:(NSString *)keyPath
|
||||
ofObject:(id)object
|
||||
change:(NSDictionary *)change
|
||||
context:(void *)context {
|
||||
if (context == NSAppThemeChangeContext) {
|
||||
if (rustWrapper) warp_app_os_appearance_changed(self);
|
||||
} else {
|
||||
// Any unrecognized context must belong to super
|
||||
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)applicationDidBecomeActive:(NSNotification *)note {
|
||||
if (rustWrapper) warp_app_did_become_active(note.object);
|
||||
}
|
||||
|
||||
- (void)setForceTermination {
|
||||
forceTermination = YES;
|
||||
}
|
||||
|
||||
// Unfullscreens any windows that are currently fullscreen.
|
||||
- (void)unfullscreenAllWindows:(NSApplication *)application {
|
||||
for (NSWindow *window in [application windows]) {
|
||||
if ((window.styleMask & NSWindowStyleMaskFullScreen) == NSWindowStyleMaskFullScreen) {
|
||||
[window toggleFullScreen:nil];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)application {
|
||||
BOOL okToTerminate = YES;
|
||||
|
||||
// If this is the second termination attempt after we've already hidden the app, we can go ahead
|
||||
// and terminate.
|
||||
if (terminateOnHide) {
|
||||
return NSTerminateNow;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (okToTerminate) {
|
||||
// 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
|
||||
// handled synchronously, it is processed on the event loop.
|
||||
//
|
||||
// To work around this, we enqueue the hide on the event loop and set
|
||||
// some state so we know to resume termination of the application when
|
||||
// the hide takes effect. As a fallback, if we never get notified that
|
||||
// the application was hidden, we always resume termination after 5s.
|
||||
// We deliberately do _not_ return `NSTerminateLater` here because it enables a special mode
|
||||
// of the event loop that is meant specifically for handling modals. We also make sure to
|
||||
// first exit any fullscreen windows before we hide--`NSApplication#hide` is a NOOP if there
|
||||
// are any full screen windows.
|
||||
|
||||
[self unfullscreenAllWindows:application];
|
||||
[application hide:nil];
|
||||
terminateOnHide = YES;
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[application terminate:nil];
|
||||
});
|
||||
}
|
||||
return NSTerminateCancel;
|
||||
}
|
||||
|
||||
- (void)applicationDidHide:(NSNotification *)note {
|
||||
if (terminateOnHide) {
|
||||
NSApplication *app = note.object;
|
||||
[app terminate:nil];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)applicationDidResignActive:(NSNotification *)note {
|
||||
if (rustWrapper) warp_app_did_resign_active(note.object);
|
||||
}
|
||||
|
||||
- (void)applicationWillTerminate:(NSNotification *)note {
|
||||
if (rustWrapper) warp_app_will_terminate(note.object);
|
||||
}
|
||||
|
||||
- (void)application:(NSApplication *)sender openFiles:(NSArray<NSString *> *)filenames {
|
||||
if (rustWrapper) warp_app_open_files(sender, filenames);
|
||||
}
|
||||
|
||||
- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
|
||||
if (rustWrapper) warp_app_open_urls(application, urls);
|
||||
}
|
||||
|
||||
// This is called when clicking on the app in the Dock or from Finder.
|
||||
// If there's no visible windows, we will open one.
|
||||
- (BOOL)applicationShouldHandleReopen:(NSApplication *)app hasVisibleWindows:(BOOL)flag {
|
||||
if (rustWrapper && !flag) {
|
||||
warp_app_new_window(app);
|
||||
return NO; // do nothing
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)keyWindowChanged:(NSNotification *)note {
|
||||
// We use an async dispatch here for two reasons:
|
||||
// 1. When the active window changes, this will be called twice (once for resign, once for
|
||||
// activated). We can coalesce these calls.
|
||||
// 2. When a new window is created, warp will activate it; if we recursively call back into
|
||||
// warp then we will cause the app to be mutably borrowed while already borrowed.
|
||||
if (!hasPendingActiveWindowChange) {
|
||||
hasPendingActiveWindowChange = YES;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self->hasPendingActiveWindowChange = NO;
|
||||
if (self->rustWrapper) warp_app_active_window_changed(self);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
- (void)windowMoved:(NSNotification *)note {
|
||||
// We need to use async dispatch here because the event loop in appkit calls the
|
||||
// app notification first before calling the window notification. Since we are updating
|
||||
// the window properties within the window notification, we need to make sure this
|
||||
// callback gets triggered after the window notification. Thus using the async dispatch
|
||||
// here ensures we always save the most up-to-date value within the database.
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->rustWrapper) warp_app_window_did_move(self);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)windowResized:(NSNotification *)note {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->rustWrapper) warp_app_window_did_resize(self);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)screenChanged:(NSNotification *)note {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->rustWrapper) warp_app_screen_did_change(self);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)cpuAwakened:(NSNotification *)note {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->rustWrapper) cpu_awakened(self);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)cpuWillSleep:(NSNotification *)note {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->rustWrapper) cpu_will_sleep(self);
|
||||
});
|
||||
}
|
||||
|
||||
- (void)menuNeedsUpdate:(NSMenu *)menu {
|
||||
// Trigger warp_menu_item_needs_update for every item with our class set as its represented
|
||||
// object.
|
||||
Class warpHandlerClass = [WarpCustomMenuItemHandler class];
|
||||
for (NSMenuItem *item in menu.itemArray) {
|
||||
id obj = item.representedObject;
|
||||
if ([obj isKindOfClass:warpHandlerClass]) {
|
||||
[obj itemNeedsUpdate:item];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setReachabilityListener {
|
||||
internetReachable = [[Reachability reachabilityWithHostname:@"0.0.0.0"] retain];
|
||||
|
||||
// Internet is reachable.
|
||||
internetReachable.reachableBlock = ^(Reachability *reach __unused) {
|
||||
// Update the UI on the main thread.
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->isReachable == nil || [self->isReachable intValue] == 0) {
|
||||
self->isReachable = [NSNumber numberWithBool:YES];
|
||||
if (self->rustWrapper) warp_app_internet_reachability_changed(self, YES);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Internet is not reachable.
|
||||
internetReachable.unreachableBlock = ^(Reachability *reach __unused) {
|
||||
// Update the UI on the main thread.
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->isReachable == nil || [self->isReachable intValue] > 0) {
|
||||
self->isReachable = [NSNumber numberWithBool:NO];
|
||||
if (self->rustWrapper) warp_app_internet_reachability_changed(self, NO);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Dispatch an initial call to check internet reachability so app could get notified
|
||||
// of the reachability status it starts in.
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
|
||||
BOOL internetIsReachable = [internetReachable isReachable];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self->isReachable == nil) {
|
||||
self->isReachable = [NSNumber numberWithBool:internetIsReachable];
|
||||
if (self->rustWrapper)
|
||||
warp_app_internet_reachability_changed(self, internetIsReachable);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
[internetReachable startNotifier];
|
||||
}
|
||||
|
||||
// Returns a new NSMenu in the mac dock. Gets called every time we pull up the dock menu
|
||||
- (NSMenu *)applicationDockMenu:(NSApplication *)sender {
|
||||
return self.dockMenu;
|
||||
}
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
|
||||
didReceiveNotificationResponse:(UNNotificationResponse *)response
|
||||
withCompletionHandler:(void (^)(void))completionHandler {
|
||||
// Handle what happens when the user clicks the notification. Warp doesn't support any actions
|
||||
// other than the default action currently.
|
||||
if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
|
||||
NSDictionary *userInfo = response.notification.request.content.userInfo;
|
||||
NSString *data = userInfo[@"DATA"];
|
||||
|
||||
if (rustWrapper) {
|
||||
warp_app_notification_clicked(self, response.notification.date.timeIntervalSince1970,
|
||||
data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation WarpApplication {
|
||||
// Rust expects an ivar with this name.
|
||||
void *rustWrapper;
|
||||
}
|
||||
|
||||
- (void)setForceTermination {
|
||||
WarpDelegate *delegate = (WarpDelegate *)self.delegate;
|
||||
[delegate setForceTermination];
|
||||
}
|
||||
|
||||
- (void)showModal:(NSAlert *)alert modalId:(NSUInteger)modalId {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSModalResponse response = configureAndRunModal(alert, self);
|
||||
|
||||
BOOL disable_modal = alert.suppressionButton.state == NSControlStateValueOn;
|
||||
// Subtracting `NSAlertFirstButtonReturn` from `response` yields the 0-based index of the
|
||||
// button that was actually clicked.
|
||||
warp_app_process_modal_response(self, modalId, response - NSAlertFirstButtonReturn,
|
||||
disable_modal);
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
WarpApplication *get_warp_app() {
|
||||
// Set up the delegate (once).
|
||||
// The delegate is deliberately leaked.
|
||||
WarpApplication *app = [WarpApplication sharedApplication];
|
||||
static dispatch_once_t once;
|
||||
static id sharedDelegate;
|
||||
dispatch_once(&once, ^{
|
||||
sharedDelegate = [[WarpDelegate alloc] init];
|
||||
[app setDelegate:sharedDelegate];
|
||||
|
||||
// Hack to work around the fact that warp is frequently tested as a
|
||||
// standalone (unbundled) binary.
|
||||
app.activationPolicy = NSApplicationActivationPolicyRegular;
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
// \return an empty NSMenu with the given title, setting up the delegate appropriately.
|
||||
// The result is autoreleased.
|
||||
NSMenu *make_delegated_menu(NSString *title) {
|
||||
NSMenu *result = [[[NSMenu alloc] initWithTitle:title] autorelease];
|
||||
result.delegate = (WarpDelegate *)[[WarpApplication sharedApplication] delegate];
|
||||
return result;
|
||||
}
|
||||
|
||||
// Create Services, a system-defined standard menu on macOS
|
||||
// The result is autoreleased.
|
||||
NSMenuItem *make_services_menu_item() {
|
||||
// Create the services menu. `servicesMenu` retains, so autorelease our +1 ownership.
|
||||
NSApp.servicesMenu = [[[NSMenu alloc] init] autorelease];
|
||||
|
||||
// Create menu item for it
|
||||
NSMenuItem *servicesItem = [[[NSMenuItem alloc] init] autorelease];
|
||||
servicesItem.title = @"Services";
|
||||
servicesItem.submenu = NSApp.servicesMenu;
|
||||
|
||||
return servicesItem;
|
||||
}
|
||||
|
||||
// \return a new menu item that wraps the given context pointer.
|
||||
// The pointer will be provided back to Warp in the callbacks (see menus.h).
|
||||
// The result is autoreleased.
|
||||
NSMenuItem *make_warp_custom_menu_item(void *context) {
|
||||
WarpCustomMenuItemHandler *handler =
|
||||
[[[WarpCustomMenuItemHandler alloc] initWithContext:context] autorelease];
|
||||
|
||||
// Sets action to NULL if menu item has submenu, so the menu doesn't close when item is clicked
|
||||
NSMenuItem *item = [[[NSMenuItem alloc] initWithTitle:@""
|
||||
action:@selector(itemWasTriggered:)
|
||||
keyEquivalent:@""] autorelease];
|
||||
item.representedObject = handler;
|
||||
item.target = handler;
|
||||
return item;
|
||||
}
|
||||
|
||||
NSString *executableInApplicationBundleWithIdentifier(NSString *bundle_path) {
|
||||
NSBundle *bundle = [NSBundle bundleWithPath:bundle_path];
|
||||
NSString *executable = [bundle.bundlePath stringByAppendingPathComponent:@"Contents/MacOS"];
|
||||
executable = [executable
|
||||
stringByAppendingPathComponent:[bundle
|
||||
objectForInfoDictionaryKey:(id)kCFBundleExecutableKey]];
|
||||
return executable;
|
||||
}
|
||||
|
||||
NSString *absolutePathForApplicationBundleWithIdentifier(NSString *bundle_identifier) {
|
||||
NSURL *url =
|
||||
[[NSWorkspace sharedWorkspace] URLForApplicationWithBundleIdentifier:bundle_identifier];
|
||||
return url.path;
|
||||
}
|
||||
|
||||
BOOL isVoiceOverEnabled() { return [[NSWorkspace sharedWorkspace] isVoiceOverEnabled]; }
|
||||
@@ -0,0 +1,8 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// Enforces that multiple windows don't transition to fullscreen at
|
||||
// the same time.
|
||||
@interface FullscreenWindowManager : NSObject
|
||||
// Queues a window to be transitioned to fullscreen. Not thread-safe.
|
||||
- (void)enqueueWindow:(NSWindow *)window;
|
||||
@end
|
||||
@@ -0,0 +1,60 @@
|
||||
#import "fullscreen_queue.h"
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
@implementation FullscreenWindowManager {
|
||||
// A LIFO queue of windows that want to transition to fullscreen.
|
||||
NSMutableArray<NSWindow *> *fullscreenQueue;
|
||||
|
||||
// Whether or not there is currently a window transitioning to fullscreen. Note
|
||||
// that the absence of a locking mechanism makes the FullscreenWindowManager not
|
||||
// thread-safe.
|
||||
BOOL activeTransition;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
fullscreenQueue = [[NSMutableArray alloc] init];
|
||||
activeTransition = NO;
|
||||
}
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(windowWillTransitionToFullscreen:)
|
||||
name:NSWindowWillEnterFullScreenNotification
|
||||
object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(windowDidTransitionToFullscreen:)
|
||||
name:NSWindowDidEnterFullScreenNotification
|
||||
object:nil];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)enqueueWindow:(NSWindow *)window {
|
||||
[fullscreenQueue addObject:window];
|
||||
[self transitionNextWindowInQueue];
|
||||
}
|
||||
|
||||
- (void)transitionNextWindowInQueue {
|
||||
if (activeTransition == YES) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ([fullscreenQueue count] > 0) {
|
||||
NSWindow *window = fullscreenQueue.firstObject;
|
||||
[fullscreenQueue removeObjectAtIndex:0];
|
||||
|
||||
[window performSelector:@selector(toggleFullScreen:)];
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for when a window starts a fullscreen transition.
|
||||
- (void)windowWillTransitionToFullscreen:(NSNotification *)notification {
|
||||
activeTransition = YES;
|
||||
}
|
||||
|
||||
// Callback for when a window ends a fullscreen transition.
|
||||
- (void)windowDidTransitionToFullscreen:(NSNotification *)notification {
|
||||
activeTransition = NO;
|
||||
[self transitionNextWindowInQueue];
|
||||
}
|
||||
@end
|
||||
@@ -0,0 +1,17 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
@interface NSPasteboard (Warp)
|
||||
- (NSArray *)getFilePaths;
|
||||
@end
|
||||
|
||||
/// WarpHostView is the Content view of a Warp window.
|
||||
// It is backed by a Metal CALayer.
|
||||
@interface WarpHostView : NSView <CALayerDelegate, NSTextInputClient>
|
||||
- (WarpHostView *)initWithFrame:(NSRect)frame
|
||||
metalDevice:(id)metalDevice
|
||||
enableTitlebarDrag:(BOOL)enableTitlebarDrag
|
||||
testMode:(BOOL)testMode;
|
||||
- (void)setAsyncCallback:(BOOL)shouldAsync;
|
||||
- (BOOL)keyDownImpl:(NSEvent *)event;
|
||||
@end
|
||||
@@ -0,0 +1,496 @@
|
||||
#import "host_view.h"
|
||||
|
||||
#import <Metal/Metal.h>
|
||||
|
||||
void warp_view_did_change_backing_properties(WarpHostView *, BOOL);
|
||||
void warp_view_set_frame_size(WarpHostView *, NSSize, BOOL);
|
||||
void warp_update_layer(WarpHostView *);
|
||||
BOOL warp_handle_view_event(WarpHostView *, NSEvent *, BOOL);
|
||||
BOOL warp_handle_first_mouse_event(WarpHostView *, NSEvent *);
|
||||
void warp_handle_insert_text(WarpHostView *, id);
|
||||
void warp_update_ime_state(WarpHostView *, BOOL);
|
||||
void warp_handle_drag_and_drop(WarpHostView *, NSArray *, NSPoint);
|
||||
void warp_handle_file_drag(WarpHostView *, NSPoint);
|
||||
void warp_handle_file_drag_exit(WarpHostView *);
|
||||
NSRect warp_ime_position(WarpHostView *, NSRect *);
|
||||
id warp_get_accessibility_contents(WarpHostView *);
|
||||
void warp_marked_text_updated(WarpHostView *, NSString *, NSRange);
|
||||
void warp_marked_text_cleared(WarpHostView *);
|
||||
|
||||
@implementation NSPasteboard (Warp)
|
||||
|
||||
- (NSArray *)getFilePaths {
|
||||
NSMutableArray *paths = [NSMutableArray array];
|
||||
NSArray<NSURL *> *urls = [self readObjectsForClasses:@[ [NSURL class] ] options:0];
|
||||
for (NSURL *url in urls) {
|
||||
NSString *path = url.path;
|
||||
if (path) {
|
||||
[paths addObject:path];
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation WarpHostView {
|
||||
// The windowState is managed on the Rust side.
|
||||
// Note Rust expects this name even though we are not a window.
|
||||
void *windowState;
|
||||
|
||||
// Whether we start a window drag on an unhandled mouseDown event inside the title bar
|
||||
BOOL titlebarDragEnabled;
|
||||
|
||||
// Whether we are in test mode, which suppresses drawing.
|
||||
BOOL testMode;
|
||||
|
||||
// The metal device for our layer.
|
||||
id metalDevice;
|
||||
|
||||
NSMutableAttributedString *markedText;
|
||||
NSMutableString *textToInsert;
|
||||
|
||||
// Whether to have resize event callback called asynchronously.
|
||||
BOOL asyncCallback;
|
||||
|
||||
// Whether we're in the middle of a call to interpretKeyEvents.
|
||||
BOOL interpretingKeyEvents;
|
||||
}
|
||||
|
||||
- (BOOL)acceptsFirstResponder {
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)mouseDownCanMoveWindow {
|
||||
return !titlebarDragEnabled;
|
||||
}
|
||||
|
||||
- (BOOL)readyForWarp {
|
||||
return windowState != NULL;
|
||||
}
|
||||
|
||||
/// Returns the height of the titlebar.
|
||||
- (CGFloat)titlebarHeight {
|
||||
NSButton *closeButton = [self.window standardWindowButton:NSWindowCloseButton];
|
||||
NSView *titlebar = [closeButton superview];
|
||||
return titlebar.frame.size.height;
|
||||
}
|
||||
|
||||
- (BOOL)mouseInTitleBar:(NSEvent *)event {
|
||||
NSPoint windowLoc = [self convertPoint:event.locationInWindow fromView:nil];
|
||||
// windowLoc.y is the distance from the bottom of the window to the cursor
|
||||
// NSHeight(window.frame) will be the height of the whole window, so
|
||||
// NSHeight - titlebarHeight will be the bottom border of the titlebar
|
||||
return NSHeight(self.window.frame) - [self titlebarHeight] <= windowLoc.y;
|
||||
}
|
||||
|
||||
// See if the user double clicked in the titlebar. If so, do whatever
|
||||
// action is given by preferences.
|
||||
// \return true if handled, false otherwise.
|
||||
- (BOOL)handleTitleBarDoubleClick:(NSEvent *)event {
|
||||
NSWindow *window = self.window;
|
||||
NSWindowStyleMask styleMask = window.styleMask;
|
||||
// Was this a double click in a full-sized content view, not in full screen?
|
||||
if (event.clickCount != 2) return NO;
|
||||
if (!(styleMask & NSWindowStyleMaskFullSizeContentView)) return NO;
|
||||
if (styleMask & NSWindowStyleMaskFullScreen) return NO;
|
||||
|
||||
// See if our point is in the titlebar of the window.
|
||||
if (![self mouseInTitleBar:event]) return NO;
|
||||
|
||||
// Ok, do the action.
|
||||
NSString *action =
|
||||
[[NSUserDefaults standardUserDefaults] objectForKey:@"AppleActionOnDoubleClick"];
|
||||
|
||||
// When user has not explicitly ticked or unticked the `Double-click the window's
|
||||
// title bar to` option in system preferences, the NSUserDefaults will not have the key
|
||||
// "AppleActionOnDoubleClick", despite in system preferences the default is to "Zoom".
|
||||
// To make the behavior consistent, when the key is nil, we set performZoom as the
|
||||
// default behavior here.
|
||||
if ([action isEqualToString:@"Minimize"]) {
|
||||
[window performMiniaturize:nil];
|
||||
return YES;
|
||||
} else if (action == nil || [action isEqualToString:@"Maximize"]) {
|
||||
[window performZoom:nil];
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)viewDidChangeBackingProperties {
|
||||
if (self.readyForWarp) warp_view_did_change_backing_properties(self, asyncCallback);
|
||||
[super viewDidChangeBackingProperties];
|
||||
}
|
||||
|
||||
- (void)setFrameSize:(NSSize)size {
|
||||
BOOL changed = !NSEqualSizes(size, self.frame.size);
|
||||
// We could receive invalid frame sizes when the window is moved offscreen.
|
||||
// Validate the size against the minimum drawable size of the window before
|
||||
// passing to the rust side.
|
||||
if (size.height >= self.window.minSize.height && size.width >= self.window.minSize.width) {
|
||||
[super setFrameSize:size];
|
||||
// It's an important optimization to only invoke this if the size changed.
|
||||
if (self.readyForWarp && changed) {
|
||||
warp_view_set_frame_size(self, size, asyncCallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)displayLayer:(CALayer *)layer {
|
||||
if (!testMode && self.readyForWarp) {
|
||||
warp_update_layer(self);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setAsyncCallback:(BOOL)shouldAsync {
|
||||
asyncCallback = shouldAsync;
|
||||
}
|
||||
|
||||
- (void)keyDown:(NSEvent *)event {
|
||||
[self keyDownImpl:event];
|
||||
}
|
||||
|
||||
- (BOOL)keyDownImpl:(NSEvent *)event {
|
||||
BOOL wasComposing = [self hasMarkedText];
|
||||
[textToInsert setString:@""];
|
||||
|
||||
// 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.
|
||||
interpretingKeyEvents = YES;
|
||||
[self interpretKeyEvents:[NSArray arrayWithObject:event]];
|
||||
interpretingKeyEvents = NO;
|
||||
|
||||
BOOL handled = NO;
|
||||
if (self.readyForWarp) {
|
||||
handled = warp_handle_view_event(self, event, wasComposing || [self hasMarkedText]);
|
||||
}
|
||||
|
||||
// It's possible to have keybinding conflicts between terminal apps which use the meta key and
|
||||
// MacOS "dead keys". Dead keys are used to add diacritical marks to other characters, and they
|
||||
// start composing marked text. To detect if a keybinding was triggered in the app, `handled`
|
||||
// will be true. If that is the case, we don't want MacOS to also start composing because we
|
||||
// already handled that keydown elsewhere. So, if `justStartedComposing` is also true, clear
|
||||
// out the marked text.
|
||||
// https://support.apple.com/guide/mac-help/enter-characters-with-accent-marks-on-mac-mh27474/mac#mchl45cdda7f
|
||||
BOOL justStartedComposing = !wasComposing && [self hasMarkedText];
|
||||
if (handled && justStartedComposing) {
|
||||
NSTextInputContext *inputContext = [self inputContext];
|
||||
[inputContext discardMarkedText];
|
||||
[self unmarkText];
|
||||
}
|
||||
|
||||
// Dispatch TypedCharacter event after KeyDown has been dispatched.
|
||||
if ([textToInsert length] > 0 && !handled) {
|
||||
warp_handle_insert_text(self, (NSString *)textToInsert);
|
||||
[self unmarkText];
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
- (BOOL)acceptsFirstMouse:(NSEvent *)event {
|
||||
// We want to receive mouseDown events even if the window is not key
|
||||
// and we explicity fire the event here so that Warp can handle it.
|
||||
if (self.readyForWarp) warp_handle_first_mouse_event(self, event);
|
||||
|
||||
// We return NO though so that the event is not fired twice (returning YES
|
||||
// would result in the event being passed to the mouseDown handler).
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)mouseDown:(NSEvent *)event {
|
||||
if (self.readyForWarp) {
|
||||
BOOL eventHandled = warp_handle_view_event(self, event, NO);
|
||||
if (self->titlebarDragEnabled && !eventHandled && [self mouseInTitleBar:event]) {
|
||||
// If Warp doesn't do anything with the event, indicated by returning `false`, and
|
||||
// if the drag starts in the titlebar, begin dragging the window
|
||||
[self.window performWindowDragWithEvent:event];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)mouseUp:(NSEvent *)event {
|
||||
// Our content view is full-size so we don't get the default behavior
|
||||
// on titlebar clicks. Implement it manually.
|
||||
BOOL warp_handled = NO;
|
||||
if (self.readyForWarp) {
|
||||
warp_handled = warp_handle_view_event(self, event, NO);
|
||||
}
|
||||
if (!warp_handled) {
|
||||
[self handleTitleBarDoubleClick:event];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)otherMouseDown:(NSEvent *)event {
|
||||
if (self.readyForWarp) warp_handle_view_event(self, event, NO);
|
||||
}
|
||||
|
||||
- (void)rightMouseDown:(NSEvent *)event {
|
||||
if (self.readyForWarp) warp_handle_view_event(self, event, NO);
|
||||
}
|
||||
|
||||
- (void)mouseDragged:(NSEvent *)event {
|
||||
if (self.readyForWarp) warp_handle_view_event(self, event, NO);
|
||||
}
|
||||
|
||||
- (void)scrollWheel:(NSEvent *)event {
|
||||
if (self.readyForWarp) warp_handle_view_event(self, event, NO);
|
||||
}
|
||||
|
||||
- (void)mouseMoved:(NSEvent *)event {
|
||||
if (self.readyForWarp) warp_handle_view_event(self, event, NO);
|
||||
}
|
||||
|
||||
- (void)flagsChanged:(NSEvent *)event {
|
||||
if (self.readyForWarp) warp_handle_view_event(self, event, NO);
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[markedText release];
|
||||
[textToInsert release];
|
||||
[metalDevice release];
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
- (CALayer *)makeBackingLayer {
|
||||
CAMetalLayer *layer = [CAMetalLayer layer];
|
||||
layer.pixelFormat = MTLPixelFormatBGRA8Unorm;
|
||||
layer.device = metalDevice;
|
||||
layer.allowsNextDrawableTimeout = NO;
|
||||
layer.autoresizingMask = kCALayerWidthSizable | kCALayerHeightSizable;
|
||||
layer.needsDisplayOnBoundsChange = YES;
|
||||
layer.presentsWithTransaction = YES;
|
||||
layer.delegate = self;
|
||||
layer.opaque = NO;
|
||||
return layer;
|
||||
}
|
||||
|
||||
- (WarpHostView *)initWithFrame:(NSRect)frame
|
||||
metalDevice:(id)device
|
||||
enableTitlebarDrag:(BOOL)enableTitlebarDrag
|
||||
testMode:(BOOL)testModeFlag {
|
||||
NSAssert(testModeFlag || device, @"Nil metal device not in test mode");
|
||||
[super initWithFrame:frame];
|
||||
|
||||
// Register here so we could receive drag and drop events.
|
||||
[self registerForDraggedTypes:@[
|
||||
NSPasteboardTypeFileURL,
|
||||
]];
|
||||
self->testMode = testModeFlag;
|
||||
self->titlebarDragEnabled = enableTitlebarDrag;
|
||||
self->metalDevice = [device retain];
|
||||
self->markedText = [[NSMutableAttributedString alloc] init];
|
||||
self->textToInsert = [[NSMutableString alloc] init];
|
||||
self->asyncCallback = YES;
|
||||
self.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
|
||||
self.wantsLayer = YES;
|
||||
self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawDuringViewResize;
|
||||
return self;
|
||||
}
|
||||
|
||||
// Entry point for drag & drop. Check whether the source is an acceptable type and if so
|
||||
// pass it down to performDragOperaion.
|
||||
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
|
||||
NSDragOperation sourceMask = [sender draggingSourceOperationMask];
|
||||
|
||||
BOOL pasteOK =
|
||||
!![[sender draggingPasteboard] availableTypeFromArray:@[ NSPasteboardTypeFileURL ]];
|
||||
if (pasteOK && (sourceMask & NSDragOperationCopy)) {
|
||||
return NSDragOperationCopy;
|
||||
}
|
||||
return NSDragOperationNone;
|
||||
}
|
||||
|
||||
// Called continuously while the drag operation is occurring within the view
|
||||
- (NSDragOperation)draggingUpdated:(id<NSDraggingInfo>)sender {
|
||||
NSPoint dragPoint = [sender draggingLocation];
|
||||
NSPoint localPoint = [self convertPoint:dragPoint fromView:nil];
|
||||
|
||||
NSPasteboard *pasteboard = [sender draggingPasteboard];
|
||||
if (self.readyForWarp) {
|
||||
NSArray *types = [pasteboard types];
|
||||
if ([types containsObject:NSPasteboardTypeFileURL]) {
|
||||
warp_handle_file_drag(self, localPoint);
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
return NSDragOperationNone;
|
||||
}
|
||||
|
||||
- (void)draggingExited:(id<NSDraggingInfo>)sender {
|
||||
if (self.readyForWarp) {
|
||||
warp_handle_file_drag_exit(self);
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
|
||||
NSPasteboard *pasteboard = [sender draggingPasteboard];
|
||||
NSDragOperation dragOperation = [sender draggingSourceOperationMask];
|
||||
|
||||
NSPoint dragPoint = [sender draggingLocation];
|
||||
NSPoint localPoint = [self convertPoint:dragPoint fromView:nil];
|
||||
|
||||
if (self.readyForWarp && (dragOperation & NSDragOperationCopy)) {
|
||||
NSArray *types = [pasteboard types];
|
||||
if ([types containsObject:NSPasteboardTypeFileURL]) {
|
||||
warp_handle_drag_and_drop(self, [pasteboard getFilePaths], localPoint);
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)closeIMEAsync {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSTextInputContext *inputContext = [self inputContext];
|
||||
[inputContext discardMarkedText];
|
||||
|
||||
[self unmarkText];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Accessibility
|
||||
- (BOOL)isAccessibilityElement {
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (NSAccessibilityRole)accessibilityRole {
|
||||
return NSAccessibilityTextAreaRole;
|
||||
}
|
||||
|
||||
- (NSString *)accessibilityRoleDescription {
|
||||
return NSAccessibilityRoleDescriptionForUIElement(self);
|
||||
}
|
||||
|
||||
- (BOOL)isAccessibilityFocused {
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (id)accessibilityValue {
|
||||
return warp_get_accessibility_contents(self);
|
||||
}
|
||||
|
||||
- (NSInteger)accessibilityNumberOfCharacters {
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (NSInteger)accessibilityInsertionPointLineNumber {
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (NSString *)accessibilityDocument {
|
||||
return nil;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// NSTextInputClient protocol implementation
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
- (nullable NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range
|
||||
actualRange:
|
||||
(nullable NSRangePointer)actualRange {
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (NSUInteger)characterIndexForPoint:(NSPoint)thePoint {
|
||||
return (NSUInteger)0;
|
||||
}
|
||||
|
||||
// This is a no-op as we will be handling control characters in KeyDown events.
|
||||
- (void)doCommandBySelector:(SEL)selector {
|
||||
}
|
||||
|
||||
- (NSRect)firstRectForCharacterRange:(NSRange)range
|
||||
actualRange:(nullable NSRangePointer)actualRange {
|
||||
NSWindow *window = self.window;
|
||||
if (self.readyForWarp) {
|
||||
NSRect contentRect = [window contentRectForFrameRect:[window frame]];
|
||||
NSRect rect = warp_ime_position(self, &contentRect);
|
||||
return rect;
|
||||
} else {
|
||||
return NSZeroRect;
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)hasMarkedText {
|
||||
return [markedText length] > 0;
|
||||
}
|
||||
|
||||
// Referenced glfw for this implementation.
|
||||
// https://github.com/glfw/glfw/blob/7ef34eb06de54dd9186d3d21a401b2ef819b59e7/src/cocoa_window.m#L814
|
||||
- (void)insertText:(id)string replacementRange:(NSRange)replacementRange {
|
||||
if (self.readyForWarp) {
|
||||
NSMutableString *characters = [[NSMutableString alloc] init];
|
||||
|
||||
if ([string isKindOfClass:[NSAttributedString class]]) {
|
||||
// We are appending rather than replacing here because sometimes insertText
|
||||
// could be fired multiple times in a row. For example, when user types
|
||||
// Option-E followed by g, insertText will fire ´ first and then g.
|
||||
[characters appendString:[string string]];
|
||||
} else {
|
||||
[characters appendString:(NSString *)string];
|
||||
}
|
||||
|
||||
// If we're in the middle of a call to interpretKeyEvents, batch up all
|
||||
// inserted text, as we may handle the event during `keyDown`. If this
|
||||
// call to `insertText` is not in a call stack underneath `keyDown`
|
||||
// (e.g.: when inserting an emoji from the emoji composer), just insert
|
||||
// the text directly.
|
||||
if (interpretingKeyEvents) {
|
||||
[textToInsert appendString:characters];
|
||||
} else {
|
||||
warp_handle_insert_text(self, (NSString *)characters);
|
||||
}
|
||||
|
||||
[characters release];
|
||||
}
|
||||
// When handling the key down Enter, we might need to rely on the IME being open
|
||||
// to accept the marked text as-is and so can't call unmarkText.
|
||||
if (!interpretingKeyEvents) {
|
||||
[self unmarkText];
|
||||
}
|
||||
}
|
||||
|
||||
- (NSRange)markedRange {
|
||||
if ([markedText length] > 0)
|
||||
return NSMakeRange(0, [markedText length]);
|
||||
else
|
||||
return NSMakeRange(NSNotFound, 0);
|
||||
}
|
||||
|
||||
- (NSRange)selectedRange {
|
||||
return NSMakeRange(0, 0);
|
||||
}
|
||||
|
||||
- (void)setMarkedText:(id)string
|
||||
selectedRange:(NSRange)selectedRange
|
||||
replacementRange:(NSRange)replacementRange {
|
||||
[markedText release];
|
||||
if ([string isKindOfClass:[NSAttributedString class]])
|
||||
markedText = [[NSMutableAttributedString alloc] initWithAttributedString:string];
|
||||
else
|
||||
markedText = [[NSMutableAttributedString alloc] initWithString:string];
|
||||
|
||||
if (self.readyForWarp) {
|
||||
warp_marked_text_updated(self, markedText.string, selectedRange);
|
||||
if ([markedText length] > 0) {
|
||||
warp_update_ime_state(self, YES);
|
||||
} else {
|
||||
warp_update_ime_state(self, NO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)unmarkText {
|
||||
[[markedText mutableString] setString:@""];
|
||||
if (self.readyForWarp) {
|
||||
warp_update_ime_state(self, NO);
|
||||
warp_marked_text_cleared(self);
|
||||
}
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)validAttributesForMarkedText {
|
||||
return [NSArray array];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,19 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <Carbon/Carbon.h>
|
||||
|
||||
@interface WarpHotKey : NSObject {
|
||||
@public
|
||||
EventHotKeyRef _eventHotKey;
|
||||
@public
|
||||
NSUInteger _keyCode;
|
||||
@public
|
||||
NSUInteger _modifierKeys;
|
||||
}
|
||||
|
||||
- (instancetype)initWithEventHotKey:(EventHotKeyRef)eventHotKey
|
||||
keyCode:(NSUInteger)keyCode
|
||||
modifierKeys:(NSUInteger)modifierKeys;
|
||||
|
||||
- (BOOL)hotKeyKeyAndModifierEquals:(NSUInteger)keyCode modifierKeys:(NSUInteger)modifierKeys;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,24 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <Carbon/Carbon.h>
|
||||
|
||||
#import "hotkey.h"
|
||||
|
||||
@implementation WarpHotKey
|
||||
|
||||
- (instancetype)initWithEventHotKey:(EventHotKeyRef)eventHotKey
|
||||
keyCode:(NSUInteger)keyCode
|
||||
modifierKeys:(NSUInteger)modifierKeys {
|
||||
self = [super init];
|
||||
if (self) {
|
||||
_eventHotKey = eventHotKey;
|
||||
_keyCode = keyCode;
|
||||
_modifierKeys = modifierKeys;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (BOOL)hotKeyKeyAndModifierEquals:(NSUInteger)keyCode modifierKeys:(NSUInteger)modifierKeys {
|
||||
return keyCode == _keyCode && modifierKeys == _modifierKeys;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,212 @@
|
||||
#include <AppKit/AppKit.h>
|
||||
#include <Carbon/Carbon.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
|
||||
// One virtual key could map to multiple physical keys on the keyboard. So we keep
|
||||
// a mapping from key name to an array of keycodes.
|
||||
NSMutableDictionary<NSString*, NSMutableArray<NSNumber*>*>* keycodeDict;
|
||||
|
||||
BOOL IsUnicodeControl(unichar c) {
|
||||
// C0 control characters: http://unicode.org/charts/PDF/U0000.pdf
|
||||
// C1 control characters: http://unicode.org/charts/PDF/U0080.pdf
|
||||
return c <= 0x1F || (c >= 0x7F && c <= 0x9F);
|
||||
}
|
||||
|
||||
// Control character naming needs to be in sync with the corresponding rust definition in
|
||||
// `event.rs`: see
|
||||
// https://github.com/warpdotdev/warp-internal/blob/master/ui/src/platform/mac/utils.rs#L42
|
||||
// The list of control characters are referenced from chromium code here:
|
||||
// https://chromium.googlesource.com/chromium/src/+/lkgr/ui/events/keycodes/keyboard_code_conversion_mac.mm#329
|
||||
NSString* KeyFromControlKeyCode(unsigned short keyCode) {
|
||||
switch (keyCode) {
|
||||
case kVK_ANSI_KeypadEnter:
|
||||
return @"numpadenter";
|
||||
case kVK_Return:
|
||||
return @"enter";
|
||||
case kVK_Tab:
|
||||
return @"tab";
|
||||
case kVK_Delete:
|
||||
return @"backspace";
|
||||
case kVK_Escape:
|
||||
return @"escape";
|
||||
case kVK_F1:
|
||||
return @"f1";
|
||||
case kVK_F2:
|
||||
return @"f2";
|
||||
case kVK_F3:
|
||||
return @"f3";
|
||||
case kVK_F4:
|
||||
return @"f4";
|
||||
case kVK_F5:
|
||||
return @"f5";
|
||||
case kVK_F6:
|
||||
return @"f6";
|
||||
case kVK_F7:
|
||||
return @"f7";
|
||||
case kVK_F8:
|
||||
return @"f8";
|
||||
case kVK_F9:
|
||||
return @"f9";
|
||||
case kVK_F10:
|
||||
return @"f10";
|
||||
case kVK_F11:
|
||||
return @"f11";
|
||||
case kVK_F12:
|
||||
return @"f12";
|
||||
case kVK_F13:
|
||||
return @"f13";
|
||||
case kVK_F14:
|
||||
return @"f14";
|
||||
case kVK_F15:
|
||||
return @"f15";
|
||||
case kVK_F16:
|
||||
return @"f16";
|
||||
case kVK_F17:
|
||||
return @"f17";
|
||||
case kVK_F18:
|
||||
return @"f18";
|
||||
case kVK_F19:
|
||||
return @"f19";
|
||||
case kVK_F20:
|
||||
return @"f20";
|
||||
case kVK_ForwardDelete:
|
||||
return @"delete";
|
||||
case kVK_Help:
|
||||
return @"insert";
|
||||
case kVK_Home:
|
||||
return @"home";
|
||||
case kVK_PageUp:
|
||||
return @"pageup";
|
||||
case kVK_End:
|
||||
return @"end";
|
||||
case kVK_PageDown:
|
||||
return @"pagedown";
|
||||
case kVK_LeftArrow:
|
||||
return @"left";
|
||||
case kVK_RightArrow:
|
||||
return @"right";
|
||||
case kVK_DownArrow:
|
||||
return @"down";
|
||||
case kVK_UpArrow:
|
||||
return @"up";
|
||||
default:
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get the keyboard layout data
|
||||
CFDataRef GetKeyboardLayoutData() {
|
||||
TISInputSourceRef source = TISCopyCurrentKeyboardInputSource();
|
||||
CFDataRef layout_data =
|
||||
(CFDataRef)(TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData));
|
||||
if (!layout_data) {
|
||||
// TISGetInputSourceProperty returns null with some keyboard layouts (e.g. Japanese and
|
||||
// Chinese). Using TISCopyCurrentKeyboardLayoutInputSource to fix NULL return.
|
||||
source = TISCopyCurrentKeyboardLayoutInputSource();
|
||||
layout_data =
|
||||
(CFDataRef)(TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData));
|
||||
}
|
||||
return layout_data;
|
||||
}
|
||||
|
||||
// Referenced from chromium:
|
||||
// https://chromium.googlesource.com/chromium/src/+/lkgr/ui/events/keycodes/keyboard_code_conversion_mac.mm
|
||||
// Here we take the keyboard layout, keycode, modifier keys, and keyboard type to
|
||||
// determine the output character.
|
||||
// Notice that we don't yet handle multiple character case here. But this should
|
||||
// be minor given Chrome also doesn't support it.
|
||||
UniChar TranslatedUnicodeCharFromKeyCode(CFDataRef layout_data, UInt16 key_code,
|
||||
UInt32 modifier_key_state, UInt32 keyboard_type) {
|
||||
if (!layout_data) return 0xFFFD; // REPLACEMENT CHARACTER
|
||||
|
||||
const UCKeyboardLayout* keyboardLayout = (const UCKeyboardLayout*)CFDataGetBytePtr(layout_data);
|
||||
|
||||
UInt32 deadKeyState = 0;
|
||||
UniCharCount maxStringLength = 255;
|
||||
UniCharCount actualStringLength = 0;
|
||||
UniChar unicodeString[maxStringLength];
|
||||
|
||||
UCKeyTranslate(keyboardLayout, key_code, kUCKeyActionDown, modifier_key_state, keyboard_type,
|
||||
kUCKeyTranslateNoDeadKeysBit, &deadKeyState, maxStringLength,
|
||||
&actualStringLength, unicodeString);
|
||||
// TODO(kevin): Handle multiple character case. Should be rare.
|
||||
return unicodeString[0];
|
||||
}
|
||||
|
||||
// Convert keycode to its corresponding character on the keyboard.
|
||||
NSString* keyCodeToChar(UInt16 keyCode, BOOL shifted) {
|
||||
UInt32 modifier_key_state = 0;
|
||||
|
||||
// The shift key representation in Carbon is 1 << 9.
|
||||
// However, UCKeyTranslate takes the modifier keys and shift them by 8 bits. So we
|
||||
// only need to pass in 1 << 1 here.
|
||||
if (shifted) {
|
||||
modifier_key_state = 1 << 1;
|
||||
}
|
||||
|
||||
CFDataRef layout_data = GetKeyboardLayoutData();
|
||||
UniChar translated_char =
|
||||
TranslatedUnicodeCharFromKeyCode(layout_data, keyCode, modifier_key_state, LMGetKbdLast());
|
||||
|
||||
// UCKeyTranslate can't translate control characters like function keys and arrow
|
||||
// keys. We keep a separate mapping for this case. This is the same behavior as chromium:
|
||||
// https://chromium.googlesource.com/chromium/src/+/lkgr/ui/events/keycodes/keyboard_code_conversion_mac.mm#873
|
||||
if (IsUnicodeControl(translated_char)) {
|
||||
return KeyFromControlKeyCode(keyCode);
|
||||
} else {
|
||||
return [NSString stringWithFormat:@"%C", translated_char];
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<NSNumber*>* charToKeyCodes(NSString* keyChar) {
|
||||
if (keycodeDict == nil) {
|
||||
keycodeDict = [[NSMutableDictionary alloc] init];
|
||||
CFDataRef layout_data = GetKeyboardLayoutData();
|
||||
|
||||
// For every keycode.
|
||||
size_t i;
|
||||
for (i = 0; i < 128; ++i) {
|
||||
UInt32 shift_key = 1 << 1;
|
||||
|
||||
// Compute a shifted and unshifted version for one keycode.
|
||||
UniChar unshifted =
|
||||
TranslatedUnicodeCharFromKeyCode(layout_data, (UInt16)i, 0, LMGetKbdLast());
|
||||
UniChar shifted =
|
||||
TranslatedUnicodeCharFromKeyCode(layout_data, (UInt16)i, shift_key, LMGetKbdLast());
|
||||
|
||||
NSString* unshifted_str;
|
||||
if (IsUnicodeControl(unshifted)) {
|
||||
unshifted_str = KeyFromControlKeyCode(i);
|
||||
} else {
|
||||
unshifted_str = [NSString stringWithFormat:@"%C", unshifted];
|
||||
}
|
||||
|
||||
NSString* shifted_str;
|
||||
if (IsUnicodeControl(shifted)) {
|
||||
shifted_str = KeyFromControlKeyCode(i);
|
||||
} else {
|
||||
shifted_str = [NSString stringWithFormat:@"%C", shifted];
|
||||
}
|
||||
|
||||
if (unshifted_str != nil && [unshifted_str length] > 0) {
|
||||
if ([keycodeDict objectForKey:unshifted_str] == nil) {
|
||||
[keycodeDict setObject:[[[NSMutableArray alloc] init] autorelease]
|
||||
forKey:unshifted_str];
|
||||
}
|
||||
NSMutableArray* keycodes = [keycodeDict objectForKey:unshifted_str];
|
||||
[keycodes addObject:[NSNumber numberWithInt:i]];
|
||||
}
|
||||
|
||||
if (shifted_str != nil && [shifted_str length] > 0) {
|
||||
if ([keycodeDict objectForKey:shifted_str] == nil) {
|
||||
[keycodeDict setObject:[[[NSMutableArray alloc] init] autorelease]
|
||||
forKey:shifted_str];
|
||||
}
|
||||
NSMutableArray* keycodes = [keycodeDict objectForKey:shifted_str];
|
||||
[keycodes addObject:[NSNumber numberWithInt:i]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [keycodeDict objectForKey:keyChar];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// WarpCustomMenuItemHandler is set as both the target and represented object of NSMenuItem.
|
||||
// It gives the Rust side a chance to dynamically update menu items, and
|
||||
// respond to their actions.
|
||||
@interface WarpCustomMenuItemHandler : NSObject <NSMenuItemValidation> {
|
||||
void *rustContext;
|
||||
}
|
||||
|
||||
// Init, wrapping a pointer which is significant to Rust.
|
||||
- (id)initWithContext:(void *)wrapper;
|
||||
|
||||
// Action set on menu items.
|
||||
- (void)itemWasTriggered:(NSMenuItem *)item;
|
||||
|
||||
// Called when the menu item needs updating.
|
||||
- (void)itemNeedsUpdate:(NSMenuItem *)item;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,42 @@
|
||||
#import "menus.h"
|
||||
|
||||
void warp_menu_item_needs_update(NSMenuItem *, void *);
|
||||
void warp_menu_item_triggered(NSMenuItem *, void *);
|
||||
void warp_menu_item_deallocated(void *);
|
||||
|
||||
@implementation WarpCustomMenuItemHandler
|
||||
|
||||
- (id)initWithContext:(void *)context {
|
||||
self = [super init];
|
||||
rustContext = context;
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)itemWasTriggered:(NSMenuItem *)item {
|
||||
if (rustContext && ![item hasSubmenu]) warp_menu_item_triggered(item, rustContext);
|
||||
}
|
||||
|
||||
- (void)itemNeedsUpdate:(NSMenuItem *)item {
|
||||
if (rustContext) warp_menu_item_needs_update(item, rustContext);
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
if (rustContext) warp_menu_item_deallocated(rustContext);
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
/// Our custom menu items set their enabled state in menuNeedsUpdate:, so do nothing here.
|
||||
- (BOOL)validateMenuItem:(NSMenuItem *)menuItem {
|
||||
return menuItem.isEnabled;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
void set_menu_item_submenu(NSMenuItem *item, NSMenu *submenu) {
|
||||
if (submenu == nil) {
|
||||
[item setAction:@selector(itemWasTriggered:)];
|
||||
} else {
|
||||
[item setAction:NULL];
|
||||
}
|
||||
[item setSubmenu:submenu];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# MacOS User Notifcations
|
||||
|
||||
The Apple framework we use to support notifications is `UNUserNotifications`: https://developer.apple.com/documentation/usernotifications?language=objc
|
||||
|
||||
## Developing notifications locally
|
||||
The framework needs a signed app to be able to request authorization and schedule notifications to Apple's Notification Center. For this reason, it is not enough to `cargo build && cargo run`. Instead, there are a couple of options:
|
||||
|
||||
### 1. Bundle the app
|
||||
This takes a longer time than option 2, but it is more stable, and it is what the user will ultimately experience.
|
||||
|
||||
1. Run `script/user_notifications --nouniversal --open`
|
||||
|
||||
If you want to test the authorization flow specifically, you will have to:
|
||||
1. Delete all the `WarpDev` apps you have installed locally
|
||||
2. Ensure that `WarpDev` isn't an app in your Notification Center by checking which apps show up in `Notifications` in your System Preferences.
|
||||
3. Log out*
|
||||
4. Log back in and bundle&run the app again.
|
||||
|
||||
### 2. Nosign the local build (script)
|
||||
This is less stable than option 1 and is not recommended if you're testing the authorization flow.
|
||||
|
||||
1. Ensure that you have a WarpDev app installed, _and in your Applications folder_. It's important to have the app in your Applications, or Apple won't be able to find the app while testing notifications.
|
||||
2. Run `script/local_build_and_sign`
|
||||
|
||||
If you want to test the authorization flow specifically, you will have to:
|
||||
1. Delete all the `WarpDev` apps you have installed locally, including the one in your Applications folder.
|
||||
2. Ensure that `WarpDev` isn't an app in your Notification Center by checking which apps show up in `Notifications` in your System Preferences.
|
||||
3. Log out* and log back in.
|
||||
4. Move the `WarpDev` from `Bin` to `Applications` (i.e. install `WarpDev`).
|
||||
5. Run the script to nosign and run the app again: `script/local_build_and_sign`.
|
||||
|
||||
*NB: If you already have all the permissions to send notifications, and you're not testing the authorization flow, you should be able to do the following instead of logging out & in:
|
||||
1. Delete all `WarpDev` apps.
|
||||
2. Run `sudo lsof | grep usernoted | grep db2` to find the path to a database that Notification Center uses.
|
||||
3. Run `killall usernoted && killall NotificationCenter`
|
||||
4. Run `rm <path-to-notification-center-db>`
|
||||
5. Build and run the app again (if you're not bundling, you still have to move `WarpDev` back to your `Applications` folder).
|
||||
|
||||
## Debugging notifications
|
||||
Some useful methods for debugging errors / if things aren't working as expected:
|
||||
- Check Notification Center to figure out if `WarpDev` is a registered app and to play around with the settings (e.g. turning on/off, enabling sound, etc)
|
||||
- Use `NSLog` to print debug statements in local builds
|
||||
- Use the Console app for more helpful framework errors - this is particularly helpful when you don't see any error from your own logs. Filter the messages by `NotificationCenter` or `usernoted` or `dev`
|
||||
- When in doubt, delete all `WarpDev` apps and restart your laptop. Sometimes Notification Center needs a gentle nudge.
|
||||
@@ -0,0 +1,16 @@
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// Requests authorization for notifications.
|
||||
void requestNotificationPermissions(void* callback);
|
||||
|
||||
// This method, implemented in Rust, invokes the callback to allow the App to
|
||||
// take action when the user has responded to the permissions request.
|
||||
void warp_on_request_notification_permissions_completed(NSUInteger outcome_type, id outcome_msg,
|
||||
void* callback);
|
||||
|
||||
// Sends a desktop notification.
|
||||
void sendNotification(id, id, id, void*, BOOL);
|
||||
|
||||
// This method, implemented in Rust, invokes the callback to allow the App to
|
||||
// take action when a notification fails to send.
|
||||
void warp_on_notification_send_error(NSUInteger error_type, id error_msg, void* callback);
|
||||
@@ -0,0 +1,101 @@
|
||||
#import "notifications.h"
|
||||
#import "../app.h"
|
||||
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
|
||||
void requestNotificationPermissionsWithCompletionHandler(
|
||||
void (^completion_handler)(NSUInteger outcome_type, id outcome_msg)) {
|
||||
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
|
||||
|
||||
[center
|
||||
requestAuthorizationWithOptions:(UNAuthorizationOptionAlert + UNAuthorizationOptionSound +
|
||||
UNAuthorizationOptionBadge)
|
||||
completionHandler:^(BOOL granted, NSError *_Nullable error) {
|
||||
if (!granted) {
|
||||
completion_handler(1, @"User denied request to receive notifications.");
|
||||
} else if (error != nil) {
|
||||
completion_handler(2, error.localizedDescription);
|
||||
} else {
|
||||
// Create and register the notification category.
|
||||
UNNotificationCategory *CustomizedNotification = [UNNotificationCategory
|
||||
categoryWithIdentifier:@"CUSTOMIZED_NOTIFICATION"
|
||||
actions:@[]
|
||||
intentIdentifiers:@[]
|
||||
options:
|
||||
UNNotificationCategoryOptionCustomDismissAction];
|
||||
|
||||
[center
|
||||
setNotificationCategories:[NSSet
|
||||
setWithObjects:CustomizedNotification,
|
||||
nil]];
|
||||
completion_handler(0,
|
||||
@"User accepted request to receive notifications.");
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
void requestNotificationPermissions(void *on_completion_callback) {
|
||||
requestNotificationPermissionsWithCompletionHandler(^(NSUInteger outcome_type, id outcome_msg) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
warp_on_request_notification_permissions_completed(outcome_type, outcome_msg,
|
||||
on_completion_callback);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void sendNotificationWithErrorHandler(NSString *title, NSString *body, NSString *data,
|
||||
void (^error_handler)(NSUInteger error_type, id error_msg),
|
||||
BOOL playSound) {
|
||||
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
|
||||
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
|
||||
if (settings.authorizationStatus == UNAuthorizationStatusDenied) {
|
||||
error_handler(0, @"User turned permissions off in system preferences.");
|
||||
} else {
|
||||
// Create the notification content.
|
||||
// `autorelease` balances the +1 retain from `alloc`; the enclosing UserNotifications
|
||||
// completion block runs on a GCD-dispatched queue that drains an ambient pool.
|
||||
UNMutableNotificationContent *content =
|
||||
[[[UNMutableNotificationContent alloc] init] autorelease];
|
||||
content.title = [NSString localizedUserNotificationStringForKey:title arguments:nil];
|
||||
content.body = [NSString localizedUserNotificationStringForKey:body arguments:nil];
|
||||
|
||||
// Only play sound if the user setting allows it
|
||||
if (playSound) {
|
||||
content.sound = [UNNotificationSound defaultSound];
|
||||
}
|
||||
|
||||
content.userInfo = @{
|
||||
@"DATA" : data,
|
||||
};
|
||||
|
||||
// Configure the trigger to send the notification after 1 second.
|
||||
UNTimeIntervalNotificationTrigger *trigger =
|
||||
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO];
|
||||
|
||||
// Create the request object.
|
||||
UNNotificationRequest *request =
|
||||
[UNNotificationRequest requestWithIdentifier:@"CUSTOMIZED_NOTIFICATION"
|
||||
content:content
|
||||
trigger:trigger];
|
||||
|
||||
// Schedule the notification.
|
||||
[center addNotificationRequest:request
|
||||
withCompletionHandler:^(NSError *_Nullable err) {
|
||||
if (err != nil) {
|
||||
error_handler(1, err.localizedDescription);
|
||||
}
|
||||
}];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
void sendNotification(id title, id body, id data, void *on_error_callback, BOOL playSound) {
|
||||
sendNotificationWithErrorHandler(
|
||||
title, body, data,
|
||||
^(NSUInteger error_type, id error_msg) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
warp_on_notification_send_error(error_type, error_msg, on_error_callback);
|
||||
});
|
||||
},
|
||||
playSound);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (c) 2011, Tony Million.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <SystemConfiguration/SystemConfiguration.h>
|
||||
|
||||
//! Project version number for MacOSReachability.
|
||||
FOUNDATION_EXPORT double ReachabilityVersionNumber;
|
||||
|
||||
//! Project version string for MacOSReachability.
|
||||
FOUNDATION_EXPORT const unsigned char ReachabilityVersionString[];
|
||||
|
||||
/**
|
||||
* Create NS_ENUM macro if it does not exist on the targeted version of iOS or OS X.
|
||||
*
|
||||
* @see http://nshipster.com/ns_enum-ns_options/
|
||||
**/
|
||||
#ifndef NS_ENUM
|
||||
#define NS_ENUM(_type, _name) \
|
||||
enum _name : _type _name; \
|
||||
enum _name : _type
|
||||
#endif
|
||||
|
||||
extern NSString *const kReachabilityChangedNotification;
|
||||
|
||||
typedef NS_ENUM(NSInteger, NetworkStatus) {
|
||||
// Apple NetworkStatus Compatible Names.
|
||||
NotReachable = 0,
|
||||
ReachableViaWiFi = 2,
|
||||
ReachableViaWWAN = 1
|
||||
};
|
||||
|
||||
@class Reachability;
|
||||
|
||||
typedef void (^NetworkReachable)(Reachability *reachability);
|
||||
typedef void (^NetworkUnreachable)(Reachability *reachability);
|
||||
typedef void (^NetworkReachability)(Reachability *reachability, SCNetworkConnectionFlags flags);
|
||||
|
||||
@interface Reachability : NSObject
|
||||
|
||||
@property(nonatomic, copy) NetworkReachable reachableBlock;
|
||||
@property(nonatomic, copy) NetworkUnreachable unreachableBlock;
|
||||
@property(nonatomic, copy) NetworkReachability reachabilityBlock;
|
||||
|
||||
@property(nonatomic, assign) BOOL reachableOnWWAN;
|
||||
|
||||
+ (instancetype)reachabilityWithHostname:(NSString *)hostname;
|
||||
// This is identical to the function above, but is here to maintain
|
||||
// compatibility with Apples original code. (see .m)
|
||||
+ (instancetype)reachabilityWithHostName:(NSString *)hostname;
|
||||
+ (instancetype)reachabilityForInternetConnection;
|
||||
+ (instancetype)reachabilityWithAddress:(void *)hostAddress;
|
||||
+ (instancetype)reachabilityForLocalWiFi;
|
||||
+ (instancetype)reachabilityWithURL:(NSURL *)url;
|
||||
|
||||
- (instancetype)initWithReachabilityRef:(SCNetworkReachabilityRef)ref;
|
||||
|
||||
- (BOOL)startNotifier;
|
||||
- (void)stopNotifier;
|
||||
|
||||
- (BOOL)isReachable;
|
||||
- (BOOL)isReachableViaWWAN;
|
||||
- (BOOL)isReachableViaWiFi;
|
||||
|
||||
// WWAN may be available, but not active until a connection has been established.
|
||||
// WiFi may require a connection for VPN on Demand.
|
||||
- (BOOL)isConnectionRequired; // Identical DDG variant.
|
||||
- (BOOL)connectionRequired; // Apple's routine.
|
||||
// Dynamic, on demand connection?
|
||||
- (BOOL)isConnectionOnDemand;
|
||||
// Is user intervention required?
|
||||
- (BOOL)isInterventionRequired;
|
||||
|
||||
- (NetworkStatus)currentReachabilityStatus;
|
||||
- (SCNetworkReachabilityFlags)reachabilityFlags;
|
||||
- (NSString *)currentReachabilityString;
|
||||
- (NSString *)currentReachabilityFlags;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,443 @@
|
||||
/*
|
||||
Copyright (c) 2011, Tony Million.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
#import "reachability.h"
|
||||
|
||||
#import <arpa/inet.h>
|
||||
#import <ifaddrs.h>
|
||||
#import <netdb.h>
|
||||
#import <netinet/in.h>
|
||||
#import <netinet6/in6.h>
|
||||
#import <sys/socket.h>
|
||||
|
||||
NSString *const kReachabilityChangedNotification = @"kReachabilityChangedNotification";
|
||||
|
||||
@interface Reachability ()
|
||||
|
||||
@property(nonatomic, assign) SCNetworkReachabilityRef reachabilityRef;
|
||||
@property(nonatomic, strong) dispatch_queue_t reachabilitySerialQueue;
|
||||
@property(nonatomic, strong) id reachabilityObject;
|
||||
|
||||
- (void)reachabilityChanged:(SCNetworkReachabilityFlags)flags;
|
||||
- (BOOL)isReachableWithFlags:(SCNetworkReachabilityFlags)flags;
|
||||
|
||||
@end
|
||||
|
||||
static NSString *reachabilityFlags(SCNetworkReachabilityFlags flags) {
|
||||
return [NSString
|
||||
stringWithFormat:@"%c%c %c%c%c%c%c%c%c",
|
||||
#if TARGET_OS_IPHONE
|
||||
(flags & kSCNetworkReachabilityFlagsIsWWAN) ? 'W' : '-',
|
||||
#else
|
||||
'X',
|
||||
#endif
|
||||
(flags & kSCNetworkReachabilityFlagsReachable) ? 'R' : '-',
|
||||
(flags & kSCNetworkReachabilityFlagsConnectionRequired) ? 'c' : '-',
|
||||
(flags & kSCNetworkReachabilityFlagsTransientConnection) ? 't' : '-',
|
||||
(flags & kSCNetworkReachabilityFlagsInterventionRequired) ? 'i' : '-',
|
||||
(flags & kSCNetworkReachabilityFlagsConnectionOnTraffic) ? 'C' : '-',
|
||||
(flags & kSCNetworkReachabilityFlagsConnectionOnDemand) ? 'D' : '-',
|
||||
(flags & kSCNetworkReachabilityFlagsIsLocalAddress) ? 'l' : '-',
|
||||
(flags & kSCNetworkReachabilityFlagsIsDirect) ? 'd' : '-'];
|
||||
}
|
||||
|
||||
// Start listening for reachability notifications on the current run loop
|
||||
static void TMReachabilityCallback(SCNetworkReachabilityRef target,
|
||||
SCNetworkReachabilityFlags flags, void *info) {
|
||||
#pragma unused(target)
|
||||
|
||||
Reachability *reachability = ((__bridge Reachability *)info);
|
||||
|
||||
// We probably don't need an autoreleasepool here, as GCD docs state each queue has its own
|
||||
// autorelease pool, but what the heck eh?
|
||||
@autoreleasepool {
|
||||
[reachability reachabilityChanged:flags];
|
||||
}
|
||||
}
|
||||
|
||||
@implementation Reachability
|
||||
|
||||
#pragma mark - Class Constructor Methods
|
||||
|
||||
+ (instancetype)reachabilityWithHostName:(NSString *)hostname {
|
||||
return [Reachability reachabilityWithHostname:hostname];
|
||||
}
|
||||
|
||||
+ (instancetype)reachabilityWithHostname:(NSString *)hostname {
|
||||
SCNetworkReachabilityRef ref = SCNetworkReachabilityCreateWithName(NULL, [hostname UTF8String]);
|
||||
if (ref) {
|
||||
id reachability = [[[self alloc] initWithReachabilityRef:ref] autorelease];
|
||||
|
||||
return reachability;
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
+ (instancetype)reachabilityWithAddress:(void *)hostAddress {
|
||||
SCNetworkReachabilityRef ref = SCNetworkReachabilityCreateWithAddress(
|
||||
kCFAllocatorDefault, (const struct sockaddr *)hostAddress);
|
||||
if (ref) {
|
||||
id reachability = [[[self alloc] initWithReachabilityRef:ref] autorelease];
|
||||
|
||||
return reachability;
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
+ (instancetype)reachabilityForInternetConnection {
|
||||
struct sockaddr_in zeroAddress;
|
||||
bzero(&zeroAddress, sizeof(zeroAddress));
|
||||
zeroAddress.sin_len = sizeof(zeroAddress);
|
||||
zeroAddress.sin_family = AF_INET;
|
||||
|
||||
return [self reachabilityWithAddress:&zeroAddress];
|
||||
}
|
||||
|
||||
+ (instancetype)reachabilityForLocalWiFi {
|
||||
struct sockaddr_in localWifiAddress;
|
||||
bzero(&localWifiAddress, sizeof(localWifiAddress));
|
||||
localWifiAddress.sin_len = sizeof(localWifiAddress);
|
||||
localWifiAddress.sin_family = AF_INET;
|
||||
// IN_LINKLOCALNETNUM is defined in <netinet/in.h> as 169.254.0.0
|
||||
localWifiAddress.sin_addr.s_addr = htonl(IN_LINKLOCALNETNUM);
|
||||
|
||||
return [self reachabilityWithAddress:&localWifiAddress];
|
||||
}
|
||||
|
||||
+ (instancetype)reachabilityWithURL:(NSURL *)url {
|
||||
id reachability;
|
||||
|
||||
NSString *host = url.host;
|
||||
BOOL isIpAddress = [self isIpAddress:host];
|
||||
|
||||
if (isIpAddress) {
|
||||
NSNumber *port = url.port ?: [url.scheme isEqualToString:@"https"] ? @(443) : @(80);
|
||||
|
||||
struct sockaddr_in address;
|
||||
address.sin_len = sizeof(address);
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons([port intValue]);
|
||||
address.sin_addr.s_addr = inet_addr([host UTF8String]);
|
||||
|
||||
reachability = [self reachabilityWithAddress:&address];
|
||||
} else {
|
||||
reachability = [self reachabilityWithHostname:host];
|
||||
}
|
||||
|
||||
return reachability;
|
||||
}
|
||||
|
||||
+ (BOOL)isIpAddress:(NSString *)host {
|
||||
struct in_addr pin;
|
||||
return 1 == inet_aton([host UTF8String], &pin);
|
||||
}
|
||||
|
||||
// Initialization methods
|
||||
|
||||
- (instancetype)initWithReachabilityRef:(SCNetworkReachabilityRef)ref {
|
||||
self = [super init];
|
||||
if (self != nil) {
|
||||
self.reachableOnWWAN = YES;
|
||||
self.reachabilityRef = ref;
|
||||
|
||||
// We need to create a serial queue.
|
||||
// We allocate this once for the lifetime of the notifier.
|
||||
|
||||
self.reachabilitySerialQueue = dispatch_queue_create("com.tonymillion.reachability", NULL);
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopNotifier];
|
||||
|
||||
if (self.reachabilityRef) {
|
||||
CFRelease(self.reachabilityRef);
|
||||
self.reachabilityRef = nil;
|
||||
}
|
||||
|
||||
self.reachableBlock = nil;
|
||||
self.unreachableBlock = nil;
|
||||
self.reachabilityBlock = nil;
|
||||
self.reachabilitySerialQueue = nil;
|
||||
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
#pragma mark - Notifier Methods
|
||||
|
||||
// Notifier
|
||||
// NOTE: This uses GCD to trigger the blocks - they *WILL NOT* be called on THE MAIN THREAD
|
||||
// - In other words DO NOT DO ANY UI UPDATES IN THE BLOCKS.
|
||||
// INSTEAD USE dispatch_async(dispatch_get_main_queue(), ^{UISTUFF}) (or dispatch_sync if you
|
||||
// want)
|
||||
|
||||
- (BOOL)startNotifier {
|
||||
// allow start notifier to be called multiple times
|
||||
if (self.reachabilityObject && (self.reachabilityObject == self)) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
SCNetworkReachabilityContext context = {0, NULL, NULL, NULL, NULL};
|
||||
context.info = (__bridge void *)self;
|
||||
|
||||
if (SCNetworkReachabilitySetCallback(self.reachabilityRef, TMReachabilityCallback, &context)) {
|
||||
// Set it as our reachability queue, which will retain the queue
|
||||
if (SCNetworkReachabilitySetDispatchQueue(self.reachabilityRef,
|
||||
self.reachabilitySerialQueue)) {
|
||||
// this should do a retain on ourself, so as long as we're in notifier mode we shouldn't
|
||||
// disappear out from under ourselves woah
|
||||
self.reachabilityObject = self;
|
||||
return YES;
|
||||
} else {
|
||||
#ifdef DEBUG
|
||||
NSLog(@"SCNetworkReachabilitySetDispatchQueue() failed: %s", SCErrorString(SCError()));
|
||||
#endif
|
||||
|
||||
// UH OH - FAILURE - stop any callbacks!
|
||||
SCNetworkReachabilitySetCallback(self.reachabilityRef, NULL, NULL);
|
||||
}
|
||||
} else {
|
||||
#ifdef DEBUG
|
||||
NSLog(@"SCNetworkReachabilitySetCallback() failed: %s", SCErrorString(SCError()));
|
||||
#endif
|
||||
}
|
||||
|
||||
// if we get here we fail at the internet
|
||||
self.reachabilityObject = nil;
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)stopNotifier {
|
||||
// First stop, any callbacks!
|
||||
SCNetworkReachabilitySetCallback(self.reachabilityRef, NULL, NULL);
|
||||
|
||||
// Unregister target from the GCD serial dispatch queue.
|
||||
SCNetworkReachabilitySetDispatchQueue(self.reachabilityRef, NULL);
|
||||
|
||||
self.reachabilityObject = nil;
|
||||
}
|
||||
|
||||
#pragma mark - reachability tests
|
||||
|
||||
// This is for the case where you flick the airplane mode;
|
||||
// you end up getting something like this:
|
||||
// Reachability: WR ct-----
|
||||
// Reachability: -- -------
|
||||
// Reachability: WR ct-----
|
||||
// Reachability: -- -------
|
||||
// We treat this as 4 UNREACHABLE triggers - really apple should do better than this
|
||||
|
||||
#define testcase \
|
||||
(kSCNetworkReachabilityFlagsConnectionRequired | kSCNetworkReachabilityFlagsTransientConnection)
|
||||
|
||||
- (BOOL)isReachableWithFlags:(SCNetworkReachabilityFlags)flags {
|
||||
BOOL connectionUP = YES;
|
||||
|
||||
if (!(flags & kSCNetworkReachabilityFlagsReachable)) connectionUP = NO;
|
||||
|
||||
if ((flags & testcase) == testcase) connectionUP = NO;
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
if (flags & kSCNetworkReachabilityFlagsIsWWAN) {
|
||||
// We're on 3G.
|
||||
if (!self.reachableOnWWAN) {
|
||||
// We don't want to connect when on 3G.
|
||||
connectionUP = NO;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return connectionUP;
|
||||
}
|
||||
|
||||
- (BOOL)isReachable {
|
||||
SCNetworkReachabilityFlags flags;
|
||||
|
||||
if (!SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags)) return NO;
|
||||
|
||||
return [self isReachableWithFlags:flags];
|
||||
}
|
||||
|
||||
- (BOOL)isReachableViaWWAN {
|
||||
#if TARGET_OS_IPHONE
|
||||
|
||||
SCNetworkReachabilityFlags flags = 0;
|
||||
|
||||
if (SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags)) {
|
||||
// Check we're REACHABLE
|
||||
if (flags & kSCNetworkReachabilityFlagsReachable) {
|
||||
// Now, check we're on WWAN
|
||||
if (flags & kSCNetworkReachabilityFlagsIsWWAN) {
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (BOOL)isReachableViaWiFi {
|
||||
SCNetworkReachabilityFlags flags = 0;
|
||||
|
||||
if (SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags)) {
|
||||
// Check we're reachable
|
||||
if ((flags & kSCNetworkReachabilityFlagsReachable)) {
|
||||
#if TARGET_OS_IPHONE
|
||||
// Check we're NOT on WWAN
|
||||
if ((flags & kSCNetworkReachabilityFlagsIsWWAN)) {
|
||||
return NO;
|
||||
}
|
||||
#endif
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
// WWAN may be available, but not active until a connection has been established.
|
||||
// WiFi may require a connection for VPN on Demand.
|
||||
- (BOOL)isConnectionRequired {
|
||||
return [self connectionRequired];
|
||||
}
|
||||
|
||||
- (BOOL)connectionRequired {
|
||||
SCNetworkReachabilityFlags flags;
|
||||
|
||||
if (SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags)) {
|
||||
return (flags & kSCNetworkReachabilityFlagsConnectionRequired);
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Dynamic, on demand connection?
|
||||
- (BOOL)isConnectionOnDemand {
|
||||
SCNetworkReachabilityFlags flags;
|
||||
|
||||
if (SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags)) {
|
||||
return ((flags & kSCNetworkReachabilityFlagsConnectionRequired) &&
|
||||
(flags & (kSCNetworkReachabilityFlagsConnectionOnTraffic |
|
||||
kSCNetworkReachabilityFlagsConnectionOnDemand)));
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Is user intervention required?
|
||||
- (BOOL)isInterventionRequired {
|
||||
SCNetworkReachabilityFlags flags;
|
||||
|
||||
if (SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags)) {
|
||||
return ((flags & kSCNetworkReachabilityFlagsConnectionRequired) &&
|
||||
(flags & kSCNetworkReachabilityFlagsInterventionRequired));
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
#pragma mark - reachability status stuff
|
||||
|
||||
- (NetworkStatus)currentReachabilityStatus {
|
||||
if ([self isReachable]) {
|
||||
if ([self isReachableViaWiFi]) return ReachableViaWiFi;
|
||||
|
||||
#if TARGET_OS_IPHONE
|
||||
return ReachableViaWWAN;
|
||||
#endif
|
||||
}
|
||||
|
||||
return NotReachable;
|
||||
}
|
||||
|
||||
- (SCNetworkReachabilityFlags)reachabilityFlags {
|
||||
SCNetworkReachabilityFlags flags = 0;
|
||||
|
||||
if (SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags)) {
|
||||
return flags;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (NSString *)currentReachabilityString {
|
||||
NetworkStatus temp = [self currentReachabilityStatus];
|
||||
|
||||
if (temp == ReachableViaWWAN) {
|
||||
// Updated for the fact that we have CDMA phones now!
|
||||
return NSLocalizedString(@"Cellular", @"");
|
||||
}
|
||||
if (temp == ReachableViaWiFi) {
|
||||
return NSLocalizedString(@"WiFi", @"");
|
||||
}
|
||||
|
||||
return NSLocalizedString(@"No Connection", @"");
|
||||
}
|
||||
|
||||
- (NSString *)currentReachabilityFlags {
|
||||
return reachabilityFlags([self reachabilityFlags]);
|
||||
}
|
||||
|
||||
#pragma mark - Callback function calls this method
|
||||
|
||||
- (void)reachabilityChanged:(SCNetworkReachabilityFlags)flags {
|
||||
if ([self isReachableWithFlags:flags]) {
|
||||
if (self.reachableBlock) {
|
||||
self.reachableBlock(self);
|
||||
}
|
||||
} else {
|
||||
if (self.unreachableBlock) {
|
||||
self.unreachableBlock(self);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.reachabilityBlock) {
|
||||
self.reachabilityBlock(self, flags);
|
||||
}
|
||||
|
||||
// this makes sure the change notification happens on the MAIN THREAD
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:kReachabilityChangedNotification
|
||||
object:self];
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Debug Description
|
||||
|
||||
- (NSString *)description {
|
||||
NSString *description =
|
||||
[NSString stringWithFormat:@"<%@: %p (%@)>", NSStringFromClass([self class]), self,
|
||||
[self currentReachabilityFlags]];
|
||||
return description;
|
||||
}
|
||||
|
||||
@end
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
#import <Carbon/Carbon.h>
|
||||
|
||||
typedef int CGSWindowID;
|
||||
typedef void* CGSConnectionID;
|
||||
|
||||
extern CGSConnectionID CGSDefaultConnectionForThread(void);
|
||||
|
||||
// Typedef for the CGSSetWindowBackgroundBlurRadius function, which is a private
|
||||
// API.
|
||||
typedef CGError CGSSetWindowBackgroundBlurRadiusFunction(CGSConnectionID cid, CGSWindowID wid,
|
||||
NSUInteger blur);
|
||||
|
||||
// Returns a function pointer to the private CGSSetWindowBackgroundBlurRadius
|
||||
// API, which can be used to set the background blur radius for an NSWindow.
|
||||
CGSSetWindowBackgroundBlurRadiusFunction* GetCGSSetWindowBackgroundBlurRadiusFunction(void);
|
||||
@@ -0,0 +1,34 @@
|
||||
#import "window_blur.h"
|
||||
|
||||
static NSString *const kApplicationServicesFramework =
|
||||
@"/System/Library/Frameworks/ApplicationServices.framework";
|
||||
|
||||
// Returns a function pointer to the private function named `func` in the given
|
||||
// `library`. Returns NULL if the function does not exist.
|
||||
static void *GetFunctionByName(NSString *library, char *func) {
|
||||
CFBundleRef bundle;
|
||||
CFURLRef bundleURL = CFURLCreateWithFileSystemPath(kCFAllocatorDefault, (CFStringRef)library,
|
||||
kCFURLPOSIXPathStyle, true);
|
||||
CFStringRef functionName =
|
||||
CFStringCreateWithCString(kCFAllocatorDefault, func, kCFStringEncodingASCII);
|
||||
bundle = CFBundleCreate(kCFAllocatorDefault, bundleURL);
|
||||
void *f = NULL;
|
||||
if (bundle) {
|
||||
f = CFBundleGetFunctionPointerForName(bundle, functionName);
|
||||
CFRelease(bundle);
|
||||
}
|
||||
CFRelease(functionName);
|
||||
CFRelease(bundleURL);
|
||||
return f;
|
||||
}
|
||||
|
||||
CGSSetWindowBackgroundBlurRadiusFunction *GetCGSSetWindowBackgroundBlurRadiusFunction(void) {
|
||||
static BOOL tried = NO;
|
||||
static CGSSetWindowBackgroundBlurRadiusFunction *function = NULL;
|
||||
if (!tried) {
|
||||
function =
|
||||
GetFunctionByName(kApplicationServicesFramework, "CGSSetWindowBackgroundBlurRadius");
|
||||
tried = YES;
|
||||
}
|
||||
return function;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use metal::{MTLPixelFormat, MTLStorageMode};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use galaxyui_core::platform::CapturedFrame;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "frame_capture_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Captures a rendered frame from a Metal texture and returns the raw BGRA pixel data.
|
||||
///
|
||||
/// The data is returned in Metal's native BGRA format to avoid an expensive
|
||||
/// pixel-format conversion on the render thread. Consumers that need RGBA
|
||||
/// should call `CapturedFrame::ensure_rgba()`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `texture` - The Metal texture containing the rendered frame
|
||||
/// * `size` - The dimensions of the texture (width, height)
|
||||
///
|
||||
/// # 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> {
|
||||
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);
|
||||
return None;
|
||||
}
|
||||
|
||||
let bytes_per_row = width * 4;
|
||||
let buffer_size = bytes_per_row * height;
|
||||
|
||||
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,
|
||||
depth: 1,
|
||||
},
|
||||
};
|
||||
|
||||
texture.get_bytes(
|
||||
pixel_data.as_mut_ptr() as *mut std::ffi::c_void,
|
||||
bytes_per_row as u64,
|
||||
region,
|
||||
0,
|
||||
);
|
||||
|
||||
Some(CapturedFrame::new_bgra(
|
||||
width as u32,
|
||||
height as u32,
|
||||
pixel_data,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn convert_bgra_to_rgba(data: &mut [u8]) {
|
||||
for chunk in data.chunks_exact_mut(4) {
|
||||
chunk.swap(0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an off-screen Metal texture
|
||||
///
|
||||
/// This is a utility function for headless/off-screen rendering scenarios where
|
||||
/// you need to render to a texture rather than a window drawable. Currently unused
|
||||
/// but kept for future headless capture or visual regression testing support.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `device` - The Metal device to create the texture on
|
||||
/// * `width` - The width of the texture in pixels
|
||||
/// * `height` - The height of the texture in pixels
|
||||
/// * `pixel_format` - The pixel format (should match the drawable format)
|
||||
///
|
||||
/// # Returns
|
||||
/// * 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,
|
||||
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);
|
||||
|
||||
// Set usage flags for rendering and reading
|
||||
texture_descriptor
|
||||
.set_usage(metal::MTLTextureUsage::RenderTarget | metal::MTLTextureUsage::ShaderRead);
|
||||
|
||||
// Use managed storage mode so we can read it back
|
||||
texture_descriptor.set_storage_mode(MTLStorageMode::Managed);
|
||||
|
||||
device.new_texture(&texture_descriptor)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use super::convert_bgra_to_rgba;
|
||||
|
||||
#[test]
|
||||
fn test_convert_bgra_to_rgba() {
|
||||
let mut data = vec![
|
||||
0xBB, 0xCC, 0xFF, 0xAA, // BGRA pixel (Blue, Green, Red, Alpha)
|
||||
0x11, 0x22, 0x33, 0x44, // Another BGRA pixel
|
||||
];
|
||||
|
||||
convert_bgra_to_rgba(&mut data);
|
||||
|
||||
// After conversion, should be RGBA (Red, Green, Blue, Alpha)
|
||||
assert_eq!(
|
||||
data,
|
||||
vec![
|
||||
0xFF, 0xCC, 0xBB, 0xAA, // RGBA pixel
|
||||
0x33, 0x22, 0x11, 0x44, // Another RGBA pixel
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub mod frame_capture;
|
||||
mod renderer;
|
||||
mod renderer_manager;
|
||||
|
||||
pub use renderer_manager::RendererManager;
|
||||
|
||||
/// Returns `true` if the given metal Device corresponds to the low power/integrated GPU.
|
||||
///
|
||||
/// 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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
use crate::platform::mac::rendering::metal::renderer::Renderer;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use galaxyui_core::rendering;
|
||||
|
||||
pub struct RendererManager {
|
||||
/// Maps a device's registry ID to its renderer (collection of state related
|
||||
/// to rendering on a particular device).
|
||||
renderers: HashMap<u64, Renderer>,
|
||||
}
|
||||
|
||||
impl RendererManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
renderers: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn renderer_for_device(&mut self, device: &metal::Device) -> &mut Renderer {
|
||||
use std::collections::hash_map::Entry::*;
|
||||
match self.renderers.entry(device.registry_id()) {
|
||||
Occupied(entry) => entry.into_mut(),
|
||||
Vacant(entry) => entry.insert(Renderer::new(
|
||||
device,
|
||||
metal::MTLPixelFormat::BGRA8Unorm,
|
||||
rendering::GlyphConfig::default(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#ifndef shader_types_h
|
||||
#define shader_types_h
|
||||
|
||||
#include <simd/simd.h>
|
||||
|
||||
typedef struct {
|
||||
vector_float2 viewport_size;
|
||||
} Uniforms;
|
||||
|
||||
typedef struct {
|
||||
vector_float2 origin;
|
||||
vector_float2 size;
|
||||
float corner_radius_top_left;
|
||||
float corner_radius_top_right;
|
||||
float corner_radius_bottom_left;
|
||||
float corner_radius_bottom_right;
|
||||
float border_top;
|
||||
float border_right;
|
||||
float border_bottom;
|
||||
float border_left;
|
||||
vector_float2 background_start;
|
||||
vector_float2 background_end;
|
||||
vector_float4 background_start_color;
|
||||
vector_float4 background_end_color;
|
||||
vector_float2 border_start;
|
||||
vector_float2 border_end;
|
||||
vector_float4 border_start_color;
|
||||
vector_float4 border_end_color;
|
||||
vector_float4 icon_color;
|
||||
int is_icon;
|
||||
vector_float2 drop_shadow_offsets;
|
||||
vector_float4 drop_shadow_color;
|
||||
float drop_shadow_sigma;
|
||||
float drop_shadow_padding_factor;
|
||||
float dash_length;
|
||||
vector_float2 gap_lengths;
|
||||
} PerRectUniforms;
|
||||
|
||||
typedef struct {
|
||||
vector_float2 origin;
|
||||
vector_float2 size;
|
||||
float uv_left;
|
||||
float uv_top;
|
||||
float uv_width;
|
||||
float uv_height;
|
||||
float fade_start;
|
||||
float fade_end;
|
||||
vector_float4 color;
|
||||
int is_emoji;
|
||||
} PerGlyphUniforms;
|
||||
|
||||
#endif // shader_types_h
|
||||
@@ -0,0 +1,402 @@
|
||||
#include <metal_stdlib>
|
||||
|
||||
using namespace metal;
|
||||
|
||||
#include "shader_types.h"
|
||||
|
||||
constant float EPSILON = 0.00001;
|
||||
|
||||
// Vertex shader outputs and fragment shader inputs
|
||||
struct RectFragmentData
|
||||
{
|
||||
float4 position [[position]];
|
||||
float2 pixel_position [[pixel_position]];
|
||||
float2 rect_origin;
|
||||
float2 rect_size;
|
||||
float2 rect_center;
|
||||
float2 rect_corner;
|
||||
float border_top;
|
||||
float border_right;
|
||||
float border_bottom;
|
||||
float border_left;
|
||||
float corner_radius_top_left;
|
||||
float corner_radius_top_right;
|
||||
float corner_radius_bottom_left;
|
||||
float corner_radius_bottom_right;
|
||||
float2 background_start;
|
||||
float2 background_end;
|
||||
float4 background_start_color;
|
||||
float4 background_end_color;
|
||||
float2 border_start;
|
||||
float2 border_end;
|
||||
float4 border_start_color;
|
||||
float4 border_end_color;
|
||||
float2 texture_coordinate;
|
||||
bool is_icon;
|
||||
float4 icon_color;
|
||||
float2 drop_shadow_offsets;
|
||||
float4 drop_shadow_color;
|
||||
float drop_shadow_sigma;
|
||||
float drop_shadow_padding_factor;
|
||||
float dash_length;
|
||||
float2 gap_lengths;
|
||||
};
|
||||
|
||||
struct GlyphFragmentData
|
||||
{
|
||||
float4 position [[position]];
|
||||
float2 rect_center;
|
||||
float2 rect_corner;
|
||||
float2 texture_coordinate;
|
||||
float fade_alpha;
|
||||
float4 color;
|
||||
bool is_emoji;
|
||||
};
|
||||
|
||||
|
||||
float distance_from_rect(vector_float2 pixel_pos, vector_float2 rect_center, vector_float2 rect_corner, float corner_radius) {
|
||||
vector_float2 p = pixel_pos - rect_center;
|
||||
vector_float2 q = abs(p) - rect_corner + corner_radius;
|
||||
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - corner_radius;
|
||||
}
|
||||
|
||||
float4 derive_color(float2 pixel_pos, float2 start, float2 end, float4 start_color, float4 end_color) {
|
||||
float2 adjusted_end = end - start;
|
||||
float h = dot(pixel_pos - start, adjusted_end) / dot(adjusted_end, adjusted_end);
|
||||
return mix(start_color, end_color, h);
|
||||
}
|
||||
|
||||
vertex RectFragmentData
|
||||
rect_vertex_shader(
|
||||
uint vertex_id [[vertex_id]],
|
||||
uint instance_id [[instance_id]],
|
||||
constant float2 *vertices [[buffer(0)]],
|
||||
constant PerRectUniforms *glyph_uniforms [[buffer(1)]],
|
||||
constant Uniforms *uniforms [[buffer(2)]])
|
||||
{
|
||||
const constant PerRectUniforms *rect = &glyph_uniforms[instance_id];
|
||||
|
||||
float2 pixel_pos = vertices[vertex_id] * rect->size + rect->origin;
|
||||
float2 device_pos = pixel_pos / uniforms->viewport_size * float2(2.0, -2.0) + float2(-1.0, 1.0);
|
||||
|
||||
RectFragmentData out;
|
||||
out.position = float4(device_pos, 0.0, 1.0);
|
||||
out.pixel_position = pixel_pos;
|
||||
out.rect_origin = rect->origin;
|
||||
out.rect_size = rect->size;
|
||||
out.rect_corner = rect->size / 2.0;
|
||||
out.rect_center = rect->origin + out.rect_corner;
|
||||
out.border_top = rect->border_top;
|
||||
out.border_right = rect->border_right;
|
||||
out.border_bottom = rect->border_bottom;
|
||||
out.border_left = rect->border_left;
|
||||
out.corner_radius_top_left = rect->corner_radius_top_left;
|
||||
out.corner_radius_top_right = rect->corner_radius_top_right;
|
||||
out.corner_radius_bottom_left = rect->corner_radius_bottom_left;
|
||||
out.corner_radius_bottom_right = rect->corner_radius_bottom_right;
|
||||
out.background_start = rect->background_start * rect->size + rect->origin;
|
||||
out.background_end = rect->background_end * rect->size + rect->origin;
|
||||
out.background_start_color = rect->background_start_color;
|
||||
out.background_end_color = rect->background_end_color;
|
||||
out.border_start = rect->border_start * rect->size + rect->origin;
|
||||
out.border_end = rect->border_end * rect->size + rect->origin;
|
||||
out.border_start_color = rect->border_start_color;
|
||||
out.border_end_color = rect->border_end_color;
|
||||
out.texture_coordinate = vertices[vertex_id];
|
||||
out.is_icon = rect->is_icon;
|
||||
out.icon_color = rect->icon_color;
|
||||
out.drop_shadow_offsets = rect->drop_shadow_offsets;
|
||||
out.drop_shadow_color = rect->drop_shadow_color;
|
||||
out.drop_shadow_sigma = rect->drop_shadow_sigma;
|
||||
out.drop_shadow_padding_factor = rect->drop_shadow_padding_factor;
|
||||
out.dash_length = rect->dash_length;
|
||||
out.gap_lengths = rect->gap_lengths;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Drop shadow code *heavily* inspired by this post:
|
||||
// http://madebyevan.com/shaders/fast-rounded-rectangle-shadows/
|
||||
|
||||
// A standard gaussian function, used for weighting samples
|
||||
float gaussian(float x, float sigma) {
|
||||
const float pi = 3.141592653589793;
|
||||
return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(2.0 * pi) * sigma);
|
||||
}
|
||||
|
||||
// This approximates the error function, needed for the gaussian integral
|
||||
float2 erf(float2 x) {
|
||||
float2 s = sign(x), a = abs(x);
|
||||
x = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a;
|
||||
x *= x;
|
||||
return s - s / (x * x);
|
||||
}
|
||||
|
||||
// Return the blurred mask along the x dimension
|
||||
float roundedBoxShadowX(float x, float y, float sigma, float corner, float2 halfSize) {
|
||||
float delta = min(halfSize.y - corner - abs(y), 0.0);
|
||||
float curved = halfSize.x - corner + sqrt(max(0.0, corner * corner - delta * delta));
|
||||
float2 integral = 0.5 + 0.5 * erf((x + float2(-curved, curved)) * (sqrt(0.5) / sigma));
|
||||
return integral.y - integral.x;
|
||||
}
|
||||
|
||||
// Return the mask for the shadow of a box from lower to upper
|
||||
float roundedBoxShadow(float2 lower, float2 upper, float2 point, float sigma, float corner) {
|
||||
// Center everything to make the math easier
|
||||
float2 center = (lower + upper) * 0.5;
|
||||
float2 halfSize = (upper - lower) * 0.5;
|
||||
point -= center;
|
||||
|
||||
// The signal is only non-zero in a limited range, so don't waste samples
|
||||
float low = point.y - halfSize.y;
|
||||
float high = point.y + halfSize.y;
|
||||
float start = clamp(-3.0 * sigma, low, high);
|
||||
float end = clamp(3.0 * sigma, low, high);
|
||||
|
||||
// Accumulate samples (we can get away with surprisingly few samples)
|
||||
float step = (end - start) / 4.0;
|
||||
float y = start + step * 0.5;
|
||||
float value = 0.0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
value += roundedBoxShadowX(point.x, point.y - y, sigma, corner, halfSize) * gaussian(y, sigma) * step;
|
||||
y += step;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
fragment float4 rect_fragment_shader(
|
||||
RectFragmentData in [[stage_in]],
|
||||
constant Uniforms *uniforms [[buffer(0)]])
|
||||
{
|
||||
float outer_distance;
|
||||
float inner_distance;
|
||||
// There are actually two different radii at play here - the inner
|
||||
// (background) and outer (shape) radii. The inner radius is equal to the
|
||||
// outer radius minus the border width, in order for the two curves to
|
||||
// maintain a constant distance from each other.
|
||||
float outer_corner_radius;
|
||||
float inner_corner_radius;
|
||||
|
||||
// Length along the perimeter of (rounded) rectangle, starting from top left.
|
||||
float length_along = 0.;
|
||||
float2 pos_from_origin = in.position.xy - in.rect_origin;
|
||||
|
||||
float2 border_inner_corner = in.rect_corner;
|
||||
if (in.position.y >= in.rect_center.y) {
|
||||
// Bottom half
|
||||
border_inner_corner.y -= in.border_bottom;
|
||||
if (in.position.x >= in.rect_center.x) {
|
||||
// Bottom right quadrant
|
||||
border_inner_corner.x -= in.border_right;
|
||||
outer_corner_radius = in.corner_radius_bottom_right;
|
||||
inner_corner_radius = max(0.0, outer_corner_radius - in.border_bottom);
|
||||
} else {
|
||||
// Bottom left quadrant
|
||||
border_inner_corner.x -= in.border_left;
|
||||
outer_corner_radius = in.corner_radius_bottom_left;
|
||||
inner_corner_radius = max(0.0, outer_corner_radius - in.border_bottom);
|
||||
}
|
||||
} else {
|
||||
// Top half
|
||||
border_inner_corner.y -= in.border_top;
|
||||
if (in.position.x >= in.rect_center.x) {
|
||||
// Top right quadrant
|
||||
border_inner_corner.x -= in.border_right;
|
||||
outer_corner_radius = in.corner_radius_top_right;
|
||||
inner_corner_radius = max(0.0, outer_corner_radius - in.border_top);
|
||||
} else {
|
||||
// Top left quadrant
|
||||
border_inner_corner.x -= in.border_left;
|
||||
outer_corner_radius = in.corner_radius_top_left;
|
||||
inner_corner_radius = max(0.0, outer_corner_radius - in.border_top);
|
||||
}
|
||||
}
|
||||
|
||||
float2 rect_bottom_right = in.rect_origin + in.rect_size;
|
||||
|
||||
outer_distance = distance_from_rect(in.position.xy, in.rect_center, in.rect_corner, outer_corner_radius);
|
||||
inner_distance = distance_from_rect(in.position.xy, in.rect_center, border_inner_corner, inner_corner_radius);
|
||||
|
||||
float4 color;
|
||||
if (in.drop_shadow_sigma > 0) {
|
||||
color = in.drop_shadow_color;
|
||||
// When we are rendering a drop shadow we need to pass in the positions
|
||||
// of the original rect, so we figure them out from the padding.
|
||||
// Note we subtract twice the padding, because the padding is specified
|
||||
// in terms of padding on a single side.
|
||||
float2 shadowed_rect_origin = in.rect_origin + in.drop_shadow_padding_factor;
|
||||
float2 shadowed_rect_size = in.rect_size - 2 * in.drop_shadow_padding_factor;
|
||||
color.a *= roundedBoxShadow(
|
||||
shadowed_rect_origin,
|
||||
shadowed_rect_origin + shadowed_rect_size,
|
||||
in.pixel_position,
|
||||
in.drop_shadow_sigma,
|
||||
outer_corner_radius);
|
||||
} else {
|
||||
// Solid fill case (not a drop shadow)
|
||||
float4 background_color = derive_color(in.position.xy, in.background_start, in.background_end, in.background_start_color, in.background_end_color);
|
||||
float4 border_color = derive_color(in.position.xy, in.border_start, in.border_end, in.border_start_color, in.border_end_color);
|
||||
|
||||
// Adjust the opacity of the border color based on where the pixel lies
|
||||
// between the background and the border.
|
||||
border_color.a *= saturate(inner_distance + 0.5);
|
||||
|
||||
// Force the alpha value to 0 (fully transparent) if the pixel is
|
||||
// outside the border.
|
||||
//
|
||||
// When we are outside the border, outer_distance is a larger positive
|
||||
// value than inner_distance. When we are inside the border itself,
|
||||
// outer_distance is negative and inner_distance is positive. When we
|
||||
// are inside the inner border edge, outer_distance is more negative
|
||||
// than inner_distance.
|
||||
border_color.a *= inner_distance > outer_distance;
|
||||
|
||||
// Masks for pixels outside of inner rectangle or on border
|
||||
bool is_horizontal_border = (in.position.y <= in.rect_origin.y + in.border_top) || (in.position.y >= rect_bottom_right.y - in.border_bottom);
|
||||
bool is_vertical_border = (in.position.x <= in.rect_origin.x + in.border_left) || (in.position.x >= rect_bottom_right.x - in.border_right);
|
||||
|
||||
// Get length along the dash and gap segment and determine if pixel is in dash or gap
|
||||
float length_on_dash_and_gap_segment_x = fmod(pos_from_origin.x, in.dash_length + in.gap_lengths.x);
|
||||
float length_on_dash_and_gap_segment_y = fmod(pos_from_origin.y, in.dash_length + in.gap_lengths.y);
|
||||
bool is_horizontal_dash = is_horizontal_border && (length_on_dash_and_gap_segment_x < in.dash_length);
|
||||
bool is_vertical_dash = is_vertical_border && (length_on_dash_and_gap_segment_y < in.dash_length);
|
||||
|
||||
// Mask out any gaps in the border
|
||||
border_color.a *= in.dash_length <= 0 || (is_horizontal_dash || is_vertical_dash);
|
||||
|
||||
// Perform proper alpha blending on the two colors, avoiding a
|
||||
// divide-by-zero if both colors are fully transparent.
|
||||
//
|
||||
// See formula for "over" compositing here: https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
|
||||
float alpha = border_color.a + background_color.a * (1.0 - border_color.a);
|
||||
color.rgb = (border_color.rgb * border_color.a + background_color.rgb * background_color.a * (1.0 - border_color.a)) / (alpha + EPSILON);
|
||||
color.a = alpha;
|
||||
}
|
||||
|
||||
// If there's a corner radius we need to do some anti aliasing to smooth out the rounded corner effect.
|
||||
if (outer_corner_radius > 0) {
|
||||
color.a *= 1.0 - saturate(outer_distance + 0.5);
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
fragment float4 image_fragment_shader(
|
||||
RectFragmentData in [[stage_in]],
|
||||
texture2d<half> color_texture [[ texture(0) ]])
|
||||
{
|
||||
constexpr sampler texture_sampler (mag_filter::linear,
|
||||
min_filter::linear);
|
||||
|
||||
// Sample the texture to obtain a color
|
||||
const half4 color_sample = color_texture.sample(texture_sampler, in.texture_coordinate);
|
||||
|
||||
float4 color;
|
||||
// If the image is an icon, use the provided icon_color instead of sampling from texture
|
||||
if (in.is_icon) {
|
||||
vector_float4 in_color = in.icon_color;
|
||||
in_color.a *= color_sample.r;
|
||||
color = float4(in_color);
|
||||
} else {
|
||||
color = float4(color_sample);
|
||||
color.a *= in.icon_color.a;
|
||||
}
|
||||
|
||||
float outer_corner_radius;
|
||||
|
||||
if (in.position.y >= in.rect_center.y) {
|
||||
// Bottom half
|
||||
if (in.position.x >= in.rect_center.x) {
|
||||
// Bottom right quadrant
|
||||
outer_corner_radius = in.corner_radius_bottom_right;
|
||||
} else {
|
||||
// Bottom left quadrant
|
||||
outer_corner_radius = in.corner_radius_bottom_left;
|
||||
}
|
||||
} else {
|
||||
// Top half
|
||||
if (in.position.x >= in.rect_center.x) {
|
||||
// Top right quadrant
|
||||
outer_corner_radius = in.corner_radius_top_right;
|
||||
} else {
|
||||
// Top left quadrant
|
||||
outer_corner_radius = in.corner_radius_top_left;
|
||||
}
|
||||
}
|
||||
|
||||
float outer_distance = distance_from_rect(in.position.xy, in.rect_center, in.rect_corner, outer_corner_radius);
|
||||
|
||||
// If there's a corner radius we need to do some anti aliasing to smooth out the rounded corner effect.
|
||||
if (outer_corner_radius > 0) {
|
||||
color.a *= 1.0 - saturate(outer_distance + 0.5);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
vertex GlyphFragmentData
|
||||
glyph_vertex_shader(
|
||||
uint vertex_id [[vertex_id]],
|
||||
uint instance_id [[instance_id]],
|
||||
constant vector_float2 *vertices [[buffer(0)]],
|
||||
const device PerGlyphUniforms *glyph_uniforms [[buffer(1)]],
|
||||
constant Uniforms *uniforms [[buffer(2)]])
|
||||
{
|
||||
const device PerGlyphUniforms *glyph = &glyph_uniforms[instance_id];
|
||||
|
||||
float2 pixel_pos = vertices[vertex_id] * glyph->size + glyph->origin;
|
||||
// Use floor here to vertically align the glyph to the pixel grid.
|
||||
// If it's not aligned to the grid, the fragment shader will do its
|
||||
// own interpolation, which makes it so we don't use the anti-aliasing
|
||||
// from core text, which is what we want. We don't force the glyph to a
|
||||
// horizontal pixel position because we rasterize the glyph at multiple
|
||||
// subpixel positions, and so the very slight linear interpolation here
|
||||
// won't produce a fuzzy glyph, just a correctly-positioned one.
|
||||
pixel_pos = float2(pixel_pos.x, floor(pixel_pos.y));
|
||||
|
||||
// Evaluating the glyphs fade effect. Note that the fade may go in two different directions:
|
||||
// - Right to left (default) - where the opaque side is on the right, and transparent on the left
|
||||
// (in this case, the start_fade < end_fade; start is where the fade is transparent)
|
||||
// - Left to right - where the opaque side is on the left, and it fades towards the right side.
|
||||
// In this case, start_fade > end_fade, and the opaque side is on the left (end_fade).
|
||||
// To clarify: fade_start is ALWAYS where the fade is transparent, and fade_end is ALWAYS where
|
||||
// the opaque part is, this is reflected in how we compute width, dist, and alpha.
|
||||
float fade_width = fabs(glyph->fade_end - glyph->fade_start);
|
||||
float fade_dist = pixel_pos.x - fmin(glyph->fade_start, glyph->fade_end);
|
||||
|
||||
float fade_alpha;
|
||||
if (glyph->fade_end < glyph->fade_start) { // left-to-right case
|
||||
fade_alpha = fade_dist / fade_width;
|
||||
} else { // right-to-left case
|
||||
fade_alpha = 1 - fade_dist / fade_width;
|
||||
}
|
||||
|
||||
vector_float2 device_pos = pixel_pos / uniforms->viewport_size * vector_float2(2.0, -2.0) + vector_float2(-1.0, 1.0);
|
||||
|
||||
vector_float2 texture_coordinate = vector_float2(glyph->uv_left, glyph->uv_top) + vertices[vertex_id] * vector_float2(glyph->uv_width, glyph->uv_height);
|
||||
|
||||
GlyphFragmentData out;
|
||||
out.position = vector_float4(device_pos, 0.0, 1.0);
|
||||
out.rect_corner = glyph->size / 2.0;
|
||||
out.rect_center = glyph->origin + out.rect_corner;
|
||||
out.texture_coordinate = texture_coordinate;
|
||||
out.fade_alpha = fade_alpha;
|
||||
out.color = glyph->color;
|
||||
out.is_emoji = glyph->is_emoji;
|
||||
return out;
|
||||
}
|
||||
|
||||
fragment float4 glyph_fragment_shader(
|
||||
GlyphFragmentData in [[stage_in]],
|
||||
texture2d<half> color_texture [[ texture(0) ]]
|
||||
) {
|
||||
// Sample the texture to obtain a color.
|
||||
constexpr sampler texture_sampler (mag_filter::linear, min_filter::linear);
|
||||
const float4 color_sample = float4(color_texture.sample(texture_sampler, in.texture_coordinate));
|
||||
// Use the input color for non-emoji, and the sampled color for emoji.
|
||||
float4 color = mix(in.color, color_sample, float(in.is_emoji));
|
||||
// Multiply alpha by the sampled color's red channel for non-emoji.
|
||||
color.a *= max(color_sample.r, float(in.is_emoji));
|
||||
// Apply the fade.
|
||||
color.a *= saturate(in.fade_alpha);
|
||||
return color;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
mod metal;
|
||||
mod renderer;
|
||||
mod renderer_manager;
|
||||
|
||||
#[cfg(wgpu)]
|
||||
mod wgpu;
|
||||
|
||||
pub use self::metal::is_integrated_gpu;
|
||||
pub use renderer::{Device, Renderer};
|
||||
pub use renderer_manager::RendererManager;
|
||||
|
||||
/// 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.
|
||||
pub fn is_low_power_gpu_available() -> bool {
|
||||
cfg_if::cfg_if! {
|
||||
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();
|
||||
gpu_count > 1
|
||||
&& devices
|
||||
.iter()
|
||||
.any(metal::is_integrated_gpu)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::platform::mac::rendering::is_integrated_gpu;
|
||||
use crate::platform::mac::window::WindowState;
|
||||
use cocoa::base::id;
|
||||
use galaxyui_core::rendering::{
|
||||
GPUBackend, GPUDeviceInfo, GPUDeviceType, GPUPowerPreference, OnGPUDeviceSelected,
|
||||
};
|
||||
use galaxyui_core::{fonts, Scene};
|
||||
|
||||
/// 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);
|
||||
|
||||
fn resize(&mut self, window: &WindowState);
|
||||
}
|
||||
|
||||
/// Set of available physical graphics devices that can be used to render.
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum Device {
|
||||
#[allow(dead_code)]
|
||||
Metal(metal::Device),
|
||||
#[cfg(wgpu)]
|
||||
WGPU(Box<crate::rendering::wgpu::Resources>),
|
||||
}
|
||||
impl Device {
|
||||
pub fn new(
|
||||
_metal_device: metal::Device,
|
||||
_native_view: id,
|
||||
_native_window: id,
|
||||
_gpu_power_preference: GPUPowerPreference,
|
||||
on_gpu_device_info: Box<OnGPUDeviceSelected>,
|
||||
) -> Self {
|
||||
#[cfg(not(wgpu))]
|
||||
{
|
||||
let gpu_device_info = get_gpu_device_info(&_metal_device);
|
||||
on_gpu_device_info(gpu_device_info);
|
||||
Device::Metal(_metal_device)
|
||||
}
|
||||
|
||||
#[cfg(wgpu)]
|
||||
{
|
||||
Device::new_wgpu(_native_view, _gpu_power_preference, on_gpu_device_info)
|
||||
.expect("unable to create wgpu device")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(wgpu, allow(dead_code))]
|
||||
fn get_gpu_device_info(device: &metal::Device) -> GPUDeviceInfo {
|
||||
let device_type = if is_integrated_gpu(device) {
|
||||
GPUDeviceType::IntegratedGpu
|
||||
} else {
|
||||
GPUDeviceType::DiscreteGpu
|
||||
};
|
||||
GPUDeviceInfo {
|
||||
device_type,
|
||||
device_name: device.name().into(),
|
||||
// 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(),
|
||||
driver_info: String::new(),
|
||||
backend: GPUBackend::Metal,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
use super::{
|
||||
metal,
|
||||
renderer::{Device, Renderer},
|
||||
};
|
||||
|
||||
pub struct RendererManager {
|
||||
metal_renderer_manager: metal::RendererManager,
|
||||
#[cfg(wgpu)]
|
||||
wgpu_renderer_manager: super::wgpu::RendererManager,
|
||||
}
|
||||
|
||||
impl Default for RendererManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
metal_renderer_manager: metal::RendererManager::new(),
|
||||
#[cfg(wgpu)]
|
||||
wgpu_renderer_manager: super::wgpu::RendererManager::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [`Renderer`] that can be used to render on the given [`Device`].
|
||||
#[allow(unused_variables)]
|
||||
pub fn renderer_for_device(
|
||||
&mut self,
|
||||
device: &Device,
|
||||
window_size: Vector2F,
|
||||
) -> &mut dyn Renderer {
|
||||
match device {
|
||||
Device::Metal(device) => self.metal_renderer_manager.renderer_for_device(device),
|
||||
#[cfg(wgpu)]
|
||||
Device::WGPU(resources) => self
|
||||
.wgpu_renderer_manager
|
||||
.renderer_for_resources(resources, window_size),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 wgpu::rwh::{
|
||||
AppKitDisplayHandle, AppKitWindowHandle, DisplayHandle, HandleError, HasDisplayHandle,
|
||||
HasWindowHandle, RawDisplayHandle, RawWindowHandle, WindowHandle,
|
||||
};
|
||||
|
||||
impl Device {
|
||||
/// Constructs a new [`Device`] to render using WGPU.
|
||||
pub fn new_wgpu(
|
||||
native_view: id,
|
||||
gpu_power_preference: GPUPowerPreference,
|
||||
on_gpu_device_info: Box<OnGPUDeviceSelected>,
|
||||
) -> Result<Device> {
|
||||
let view_frame = unsafe { NSView::frame(native_view) };
|
||||
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 window_handle =
|
||||
unsafe { WindowHandle::borrow_raw(RawWindowHandle::AppKit(appkit_window_handle)) };
|
||||
let display_handle = unsafe {
|
||||
DisplayHandle::borrow_raw(RawDisplayHandle::AppKit(AppKitDisplayHandle::new()))
|
||||
};
|
||||
|
||||
let trusted_window = TrustedWindow {
|
||||
window_handle,
|
||||
display_handle,
|
||||
};
|
||||
|
||||
crate::rendering::wgpu::init_wgpu_instance(Box::new(trusted_window));
|
||||
|
||||
let resources = Resources::new(
|
||||
trusted_window,
|
||||
gpu_power_preference,
|
||||
None,
|
||||
&on_gpu_device_info,
|
||||
surface_size,
|
||||
false, /* downrank_non_nvidia_vulkan_adapters */
|
||||
)?;
|
||||
Ok(Device::WGPU(Box::new(resources)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper struct that implements the [`HasRawWindowHandle`] and [`HasRawDisplayHandle`] traits.
|
||||
/// The raw-window-handle crate purposefully does not provide a blanket implementation of this trait
|
||||
/// for any implementation of [`RawWindowHandle`] or [`RawDisplayHandle`] because it's not
|
||||
/// 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
|
||||
/// `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.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
struct TrustedWindow {
|
||||
window_handle: WindowHandle<'static>,
|
||||
display_handle: DisplayHandle<'static>,
|
||||
}
|
||||
|
||||
// THIS IS INCREDIBLY UNSAFE!!! DO NOT DO THIS!!!
|
||||
//
|
||||
// That said, we're not using this codepath in production, and it unblocks us
|
||||
// moving to wgpu 0.19 (an important migration for the Linux target), so we're
|
||||
// doing this and covering our eyes for now, with the intention of fixing it or
|
||||
// removing support for `wpgu` in our macOS backend.
|
||||
unsafe impl Send for TrustedWindow {}
|
||||
unsafe impl Sync for TrustedWindow {}
|
||||
|
||||
impl HasWindowHandle for TrustedWindow {
|
||||
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
|
||||
Ok(self.window_handle)
|
||||
}
|
||||
}
|
||||
|
||||
impl HasDisplayHandle for TrustedWindow {
|
||||
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
|
||||
Ok(self.display_handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use crate::platform::mac::rendering::Device;
|
||||
use crate::platform::mac::window::WindowState;
|
||||
use crate::rendering::wgpu::{Renderer, Resources};
|
||||
use crate::{fonts, Scene};
|
||||
|
||||
impl super::super::Renderer for Renderer {
|
||||
fn render(&mut self, scene: &Scene, window: &WindowState, font_cache: &fonts::Cache) {
|
||||
let _ = Renderer::render(
|
||||
self,
|
||||
scene,
|
||||
window.unwrap_wgpu_resources(),
|
||||
&|glyph_key, scale, subpixel_alignment, glyph_config, format| {
|
||||
font_cache.rasterized_glyph(
|
||||
glyph_key,
|
||||
scale,
|
||||
subpixel_alignment,
|
||||
glyph_config,
|
||||
format,
|
||||
)
|
||||
},
|
||||
&|glyph_key, scale, alignment| {
|
||||
font_cache.glyph_raster_bounds(glyph_key, scale, alignment)
|
||||
},
|
||||
window.physical_size(),
|
||||
None,
|
||||
window.capture_callback.borrow_mut().take(),
|
||||
);
|
||||
}
|
||||
|
||||
fn resize(&mut self, window: &WindowState) {
|
||||
let _ = window
|
||||
.unwrap_wgpu_resources()
|
||||
.update_surface_size(window.physical_size());
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
fn unwrap_wgpu_resources(&self) -> &Resources {
|
||||
match self.device().unwrap() {
|
||||
Device::Metal(_) => {
|
||||
panic!("called the WGPU renderer with a metal device");
|
||||
}
|
||||
Device::WGPU(resources) => resources,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 wgpu::Device;
|
||||
|
||||
pub struct RendererManager {
|
||||
renderers: HashMap<DeviceID, Renderer>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Hash, Eq, PartialEq)]
|
||||
struct DeviceID(u64);
|
||||
|
||||
impl From<&Device> for DeviceID {
|
||||
fn from(value: &Device) -> Self {
|
||||
let mut s = DefaultHasher::new();
|
||||
value.hash(&mut s);
|
||||
DeviceID(s.finish())
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
renderers: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [`Renderer`] identified by the device contained in [`Resources`].
|
||||
pub fn renderer_for_resources(
|
||||
&mut self,
|
||||
resources: &Resources,
|
||||
_window_size: Vector2F,
|
||||
) -> &mut Renderer {
|
||||
use std::collections::hash_map::Entry::*;
|
||||
match self.renderers.entry((&resources.device).into()) {
|
||||
Occupied(entry) => entry.into_mut(),
|
||||
Vacant(entry) => entry.insert(Renderer::new(resources, GlyphConfig::default())),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,652 @@
|
||||
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;
|
||||
|
||||
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 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(())
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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,
|
||||
};
|
||||
|
||||
const BACKSPACE_KEY: u16 = 0x7f;
|
||||
const ENTER_KEY: u16 = 0x0d;
|
||||
const NUMPAD_ENTER_KEY: u16 = 0x03;
|
||||
const ESCAPE_KEY: u16 = 0x1b;
|
||||
const TAB_KEY: u16 = '\t' as u16;
|
||||
const SHIFTED_TAB_KEY: u16 = 0x19;
|
||||
extern "C" {
|
||||
fn CGColorGetComponents(color: CGColorRef) -> *const CGFloat;
|
||||
}
|
||||
|
||||
pub fn unicode_char_to_key(char: u16) -> Option<&'static str> {
|
||||
// Control character naming needs to be in sync with the corresponding
|
||||
// objective-c definition in `keycode.m`. See:
|
||||
// https://github.com/warpdotdev/warp-internal/blob/master/ui/src/platform/mac/objc/keycode.m#L17
|
||||
match char {
|
||||
ARROW_UP_KEY => Some("up"),
|
||||
ARROW_DOWN_KEY => Some("down"),
|
||||
ARROW_LEFT_KEY => Some("left"),
|
||||
ARROW_RIGHT_KEY => Some("right"),
|
||||
HOME_KEY => Some("home"),
|
||||
END_KEY => Some("end"),
|
||||
PAGE_UP_KEY => Some("pageup"),
|
||||
PAGE_DOWN_KEY => Some("pagedown"),
|
||||
BACKSPACE_KEY => Some("backspace"),
|
||||
ENTER_KEY => Some("enter"),
|
||||
// Mac treats the help key as synonymous with the insert key.
|
||||
HELP_KEY | INSERT_KEY => Some("insert"),
|
||||
DELETE_KEY => Some("delete"),
|
||||
ESCAPE_KEY => Some("escape"),
|
||||
TAB_KEY => Some("tab"),
|
||||
SHIFTED_TAB_KEY => Some("tab"),
|
||||
NUMPAD_ENTER_KEY => Some("numpadenter"),
|
||||
F1_FUNCTION_KEY => Some("f1"),
|
||||
F2_FUNCTION_KEY => Some("f2"),
|
||||
F3_FUNCTION_KEY => Some("f3"),
|
||||
F4_FUNCTION_KEY => Some("f4"),
|
||||
F5_FUNCTION_KEY => Some("f5"),
|
||||
F6_FUNCTION_KEY => Some("f6"),
|
||||
F7_FUNCTION_KEY => Some("f7"),
|
||||
F8_FUNCTION_KEY => Some("f8"),
|
||||
F9_FUNCTION_KEY => Some("f9"),
|
||||
F10_FUNCTION_KEY => Some("f10"),
|
||||
F11_FUNCTION_KEY => Some("f11"),
|
||||
F12_FUNCTION_KEY => Some("f12"),
|
||||
F13_FUNCTION_KEY => Some("f13"),
|
||||
F14_FUNCTION_KEY => Some("f14"),
|
||||
F15_FUNCTION_KEY => Some("f15"),
|
||||
F16_FUNCTION_KEY => Some("f16"),
|
||||
F17_FUNCTION_KEY => Some("f17"),
|
||||
F18_FUNCTION_KEY => Some("f18"),
|
||||
F19_FUNCTION_KEY => Some("f19"),
|
||||
F20_FUNCTION_KEY => Some("f20"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// 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))
|
||||
}
|
||||
|
||||
pub fn color_u_to_cg_color(color: ColorU) -> CGColor {
|
||||
CGColor::rgb(
|
||||
f64::from(color.r) / 255.,
|
||||
f64::from(color.g) / 255.,
|
||||
f64::from(color.b) / 255.,
|
||||
f64::from(color.a) / 255.,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cg_color_to_color_u(color: CGColor) -> ColorU {
|
||||
unsafe {
|
||||
let components = CGColorGetComponents(color.as_concrete_TypeRef());
|
||||
|
||||
ColorU::new(
|
||||
(*components.offset(0) * 255.) as u8,
|
||||
(*components.offset(1) * 255.) as u8,
|
||||
(*components.offset(2) * 255.) as u8,
|
||||
(*components.offset(3) * 255.) as u8,
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
pub mod app;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod mac;
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub mod wasm;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub mod windows;
|
||||
|
||||
pub mod headless;
|
||||
|
||||
pub mod current {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_family = "wasm")] {
|
||||
pub use super::wasm::*;
|
||||
} else if #[cfg(target_os = "linux")] {
|
||||
pub use super::linux::*;
|
||||
} else if #[cfg(target_os = "macos")] {
|
||||
pub use super::mac::*;
|
||||
} else if #[cfg(target_os = "windows")] {
|
||||
pub use super::windows::*;
|
||||
} else {
|
||||
pub use galaxyui_core::platform::test::*;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use galaxyui_core::platform::*;
|
||||
|
||||
pub use app::AppBuilder;
|
||||
|
||||
/// Returns whether the current device is a mobile device with touch input.
|
||||
///
|
||||
/// This is a cross-platform wrapper around the platform-specific implementation.
|
||||
pub fn is_mobile_device() -> bool {
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
wasm::is_mobile_device()
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for accessing internal per-platform concrete implementations
|
||||
/// through a wrapper type.
|
||||
#[allow(dead_code)]
|
||||
trait AsInnerMut<Inner: ?Sized> {
|
||||
fn as_inner_mut(&mut self) -> &mut Inner;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Hidden input element for triggering the soft keyboard on mobile browsers.
|
||||
//!
|
||||
//! On mobile browsers, the soft keyboard only appears when a native HTML input element
|
||||
//! is focused. This module creates and manages a hidden `<input>` element that can be
|
||||
//! programmatically focused to trigger the keyboard.
|
||||
//!
|
||||
//! ## Sentinel Character Pattern
|
||||
//!
|
||||
//! We use a "sentinel character" pattern to capture mobile keyboard input reliably:
|
||||
//! - The hidden input always contains a single space " " with the cursor after it
|
||||
//! - This ensures the keyboard always sees "deletable" text, preventing the
|
||||
//! "Android Backspace" bug where empty inputs don't emit backspace events
|
||||
//! - We listen to `input` events, process them, then reset the input
|
||||
//!
|
||||
//! Note: Cursor movement (e.g., iOS trackpad gesture) is not captured here - users tap
|
||||
//! 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 wasm_bindgen::{JsCast, JsValue};
|
||||
use web_sys::{HtmlInputElement, InputEvent, KeyboardEvent};
|
||||
|
||||
/// The ID used for the hidden input element in the DOM.
|
||||
const HIDDEN_INPUT_ID: &str = "warp-soft-keyboard-input";
|
||||
|
||||
/// The sentinel character used to ensure backspace events are always emitted.
|
||||
/// A single space ensures the keyboard always sees "deletable" content.
|
||||
const SENTINEL: &str = " ";
|
||||
|
||||
/// Manages the hidden input element used to trigger the soft keyboard.
|
||||
///
|
||||
/// This struct holds a reference to the hidden input and manages its lifecycle.
|
||||
/// It should be created once when the window is created on mobile WASM.
|
||||
pub struct HiddenInput {
|
||||
element: HtmlInputElement,
|
||||
/// Stores event listeners to keep them alive.
|
||||
/// When this struct is dropped, the listeners will be cleaned up.
|
||||
_listeners: Vec<EventListener>,
|
||||
}
|
||||
|
||||
/// Callback type for input events from the hidden input.
|
||||
pub type InputCallback = Rc<RefCell<dyn FnMut(HiddenInputEvent)>>;
|
||||
|
||||
/// Events that can be emitted by the hidden input.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HiddenInputEvent {
|
||||
/// Text was inserted via the soft keyboard.
|
||||
InsertText {
|
||||
/// The text that was inserted.
|
||||
text: String,
|
||||
},
|
||||
/// Backspace was pressed (deleteContentBackward).
|
||||
Backspace,
|
||||
/// Delete was pressed (deleteContentForward).
|
||||
Delete,
|
||||
/// The hidden input lost focus (keyboard was dismissed externally).
|
||||
Blur,
|
||||
/// A key was pressed (for keys like Enter that don't trigger input events).
|
||||
KeyDown {
|
||||
/// The key code (e.g., "Enter").
|
||||
key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl HiddenInput {
|
||||
/// Resets the hidden input to its sentinel state.
|
||||
///
|
||||
/// Sets the value to a single space and positions the cursor after it.
|
||||
/// This ensures backspace always has something to delete.
|
||||
fn reset_input_element(element: &HtmlInputElement) {
|
||||
element.set_value(SENTINEL);
|
||||
let _ = element.set_selection_range(1, 1);
|
||||
}
|
||||
|
||||
/// Creates a new hidden input element and attaches it to the DOM.
|
||||
///
|
||||
/// The input is styled to be invisible but still focusable by the browser.
|
||||
/// On mobile devices, focusing this input will trigger the soft keyboard.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `callback` - A callback that will be invoked when input events occur.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the DOM element cannot be created or configured.
|
||||
pub fn new(callback: InputCallback) -> Result<Self, JsValue> {
|
||||
let document = gloo::utils::document();
|
||||
|
||||
// Check if element already exists (e.g., from a previous session)
|
||||
if let Some(existing) = document.get_element_by_id(HIDDEN_INPUT_ID) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
// Create the input element
|
||||
let element = document
|
||||
.create_element("input")?
|
||||
.dyn_into::<HtmlInputElement>()?;
|
||||
|
||||
element.set_id(HIDDEN_INPUT_ID);
|
||||
element.set_type("text");
|
||||
|
||||
// Apply styles to make it invisible but still focusable.
|
||||
// We use a combination of techniques to ensure the input doesn't affect layout
|
||||
// or become visible, while still being able to receive focus and trigger the
|
||||
// soft keyboard on mobile.
|
||||
let style = element.style();
|
||||
style.set_property("position", "fixed")?;
|
||||
style.set_property("left", "-9999px")?;
|
||||
style.set_property("top", "0")?;
|
||||
style.set_property("opacity", "0")?;
|
||||
style.set_property("width", "1px")?;
|
||||
style.set_property("height", "1px")?;
|
||||
style.set_property("border", "none")?;
|
||||
style.set_property("outline", "none")?;
|
||||
style.set_property("padding", "0")?;
|
||||
style.set_property("margin", "0")?;
|
||||
// iOS Safari auto-zooms the viewport when focusing inputs with font-size < 16px.
|
||||
// Setting 16px prevents this unwanted zoom behavior.
|
||||
style.set_property("font-size", "16px")?;
|
||||
// Prevent the hidden input from intercepting touch/pointer events.
|
||||
// Focus/blur will still work when called programmatically.
|
||||
style.set_property("pointer-events", "none")?;
|
||||
// Ensure the input is behind everything else
|
||||
style.set_property("z-index", "-1")?;
|
||||
// Disable autocorrect/autocomplete to get raw input
|
||||
element.set_attribute("autocomplete", "off")?;
|
||||
element.set_attribute("autocorrect", "off")?;
|
||||
element.set_attribute("autocapitalize", "off")?;
|
||||
element.set_attribute("spellcheck", "false")?;
|
||||
|
||||
// Append to body
|
||||
gloo::utils::body().append_child(&element)?;
|
||||
|
||||
// Initialize with sentinel character BEFORE setting up listeners
|
||||
Self::reset_input_element(&element);
|
||||
|
||||
// Now set up event listeners
|
||||
let listeners = Self::setup_listeners(&element, callback);
|
||||
|
||||
Ok(Self {
|
||||
element,
|
||||
_listeners: listeners,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets up event listeners on the hidden input element.
|
||||
fn setup_listeners(element: &HtmlInputElement, callback: InputCallback) -> Vec<EventListener> {
|
||||
let mut listeners = Vec::new();
|
||||
|
||||
// We use 'input' event (fires after modification) because 'beforeinput' preventDefault
|
||||
// doesn't work reliably on mobile browsers (iOS Safari, Android Chrome).
|
||||
let callback_clone = Rc::clone(&callback);
|
||||
let element_clone = element.clone();
|
||||
let input_listener = EventListener::new(element, "input", move |event| {
|
||||
let input_event = event.dyn_ref::<InputEvent>();
|
||||
|
||||
// Don't process input events during IME composition.
|
||||
// Use the browser's built-in isComposing flag.
|
||||
if input_event.map(|e| e.is_composing()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
|
||||
let input_type = input_event.map(|e| e.input_type()).unwrap_or_default();
|
||||
let input_data = input_event.and_then(|e| e.data());
|
||||
|
||||
let hidden_event = match input_type.as_str() {
|
||||
"insertText" | "insertCompositionText" => input_data
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|text| HiddenInputEvent::InsertText { text }),
|
||||
// Handle both single-char and word-level deletion (long-press backspace)
|
||||
"deleteContentBackward" | "deleteWordBackward" => Some(HiddenInputEvent::Backspace),
|
||||
"deleteContentForward" => Some(HiddenInputEvent::Delete),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Always reset to sentinel state after processing
|
||||
Self::reset_input_element(&element_clone);
|
||||
|
||||
if let Some(hidden_event) = hidden_event {
|
||||
callback_clone.borrow_mut()(hidden_event);
|
||||
}
|
||||
});
|
||||
listeners.push(input_listener);
|
||||
|
||||
// Composition end event - ensures we reset the sentinel after IME composition completes.
|
||||
// This handles CJK input (Chinese, Japanese, Korean, etc.) where the final composed
|
||||
// text is committed.
|
||||
let callback_clone = Rc::clone(&callback);
|
||||
let element_clone = element.clone();
|
||||
let composition_end_listener =
|
||||
EventListener::new(element, "compositionend", move |event| {
|
||||
log::debug!("IME composition ended");
|
||||
|
||||
// Get the final composed text
|
||||
let comp_event = event.dyn_ref::<web_sys::CompositionEvent>();
|
||||
let data = comp_event.and_then(|e| e.data()).unwrap_or_default();
|
||||
|
||||
// Reset the input to sentinel state
|
||||
Self::reset_input_element(&element_clone);
|
||||
|
||||
// Send the final text if non-empty
|
||||
if !data.is_empty() {
|
||||
callback_clone.borrow_mut()(HiddenInputEvent::InsertText { text: data });
|
||||
}
|
||||
});
|
||||
listeners.push(composition_end_listener);
|
||||
|
||||
// Focus event - reset input when focused to ensure clean state
|
||||
let element_clone = element.clone();
|
||||
let focus_listener = EventListener::new(element, "focus", move |_| {
|
||||
Self::reset_input_element(&element_clone);
|
||||
});
|
||||
listeners.push(focus_listener);
|
||||
|
||||
// Blur event - fires when the hidden input loses focus (keyboard dismissed)
|
||||
let callback_clone = Rc::clone(&callback);
|
||||
let blur_listener = EventListener::new(element, "blur", move |_| {
|
||||
log::debug!("Hidden input blur event - keyboard dismissed externally");
|
||||
callback_clone.borrow_mut()(HiddenInputEvent::Blur);
|
||||
});
|
||||
listeners.push(blur_listener);
|
||||
|
||||
// Keydown event - for keys like Enter that don't trigger input events
|
||||
let callback_clone = Rc::clone(&callback);
|
||||
let keydown_listener = EventListener::new(element, "keydown", move |event| {
|
||||
if let Some(keyboard_event) = event.dyn_ref::<KeyboardEvent>() {
|
||||
let key = keyboard_event.key();
|
||||
// Only forward Enter key - other keys are handled via input events
|
||||
if key == "Enter" {
|
||||
callback_clone.borrow_mut()(HiddenInputEvent::KeyDown { key });
|
||||
}
|
||||
}
|
||||
});
|
||||
listeners.push(keydown_listener);
|
||||
|
||||
listeners
|
||||
}
|
||||
|
||||
/// Focuses the hidden input element, which triggers the soft keyboard on mobile.
|
||||
pub fn focus(&self) -> Result<(), JsValue> {
|
||||
self.element.focus()
|
||||
}
|
||||
|
||||
/// Blurs (unfocuses) the hidden input element, which dismisses the soft keyboard.
|
||||
pub fn blur(&self) -> Result<(), JsValue> {
|
||||
self.element.blur()
|
||||
}
|
||||
|
||||
/// Returns whether the hidden input currently has focus.
|
||||
pub fn has_focus(&self) -> bool {
|
||||
gloo::utils::document()
|
||||
.active_element()
|
||||
.map(|el| el.id() == HIDDEN_INPUT_ID)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resets the hidden input to its sentinel state.
|
||||
///
|
||||
/// Sets the value to a single space and positions the cursor after it.
|
||||
/// This ensures backspace always has something to delete.
|
||||
pub fn reset_input(&self) {
|
||||
Self::reset_input_element(&self.element);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HiddenInput {
|
||||
fn drop(&mut self) {
|
||||
// Remove the element from the DOM when the HiddenInput is dropped
|
||||
self.element.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Mobile device detection utilities.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
mod user_agent;
|
||||
|
||||
pub use user_agent::is_mobile_user_agent;
|
||||
|
||||
/// Cached result of mobile device detection.
|
||||
static IS_MOBILE: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Returns `true` if the current device appears to be a mobile device that would
|
||||
/// benefit from soft keyboard support.
|
||||
///
|
||||
/// This function caches its result since the device type won't change during a session.
|
||||
pub fn is_mobile_device() -> bool {
|
||||
*IS_MOBILE.get_or_init(detect_mobile_device)
|
||||
}
|
||||
|
||||
/// Performs the actual mobile device detection by checking the user agent and touch capabilities.
|
||||
fn detect_mobile_device() -> bool {
|
||||
let navigator = gloo::utils::window().navigator();
|
||||
let has_touch = navigator.max_touch_points() > 0;
|
||||
|
||||
if !has_touch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ua = navigator.user_agent().ok().unwrap_or_default();
|
||||
// Standard mobile OS (iPhone, Android, etc.) or iPad (reports as "Macintosh" with touch)
|
||||
is_mobile_user_agent(&ua) || ua.to_lowercase().contains("macintosh")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Determines if a user agent string indicates a mobile device.
|
||||
pub fn is_mobile_user_agent(user_agent: &str) -> bool {
|
||||
let ua_lower = user_agent.to_lowercase();
|
||||
|
||||
// iOS devices
|
||||
if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Android devices (phones and tablets)
|
||||
if ua_lower.contains("android") && !ua_lower.contains("windows") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Other mobile platforms
|
||||
if ua_lower.contains("webos")
|
||||
|| ua_lower.contains("blackberry")
|
||||
|| ua_lower.contains("bb10") // BlackBerry 10 devices
|
||||
|| ua_lower.contains("opera mini")
|
||||
|| ua_lower.contains("opera mobi")
|
||||
|| ua_lower.contains("iemobile")
|
||||
|| ua_lower.contains("windows phone")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "user_agent_tests.rs"]
|
||||
mod user_agent_tests;
|
||||
@@ -0,0 +1,34 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ios() {
|
||||
let ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15";
|
||||
assert!(is_mobile_user_agent(ua));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_android() {
|
||||
let ua = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 Chrome/108.0.0.0 Mobile Safari/537.36";
|
||||
assert!(is_mobile_user_agent(ua));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop() {
|
||||
assert!(!is_mobile_user_agent(
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
|
||||
));
|
||||
assert!(!is_mobile_user_agent(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_user_agent() {
|
||||
assert!(!is_mobile_user_agent(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitivity() {
|
||||
let ua = "MOZILLA/5.0 (IPHONE; CPU IPHONE OS 16_0 LIKE MAC OS X)";
|
||||
assert!(is_mobile_user_agent(ua));
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
pub(crate) mod hidden_input;
|
||||
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 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;
|
||||
|
||||
fn get_visual_viewport_dimensions() -> Option<(f32, f32)> {
|
||||
let window = gloo::utils::window();
|
||||
let vv = js_sys::Reflect::get(&window, &"visualViewport".into()).ok()?;
|
||||
let width = js_sys::Reflect::get(&vv, &"width".into())
|
||||
.ok()
|
||||
.and_then(|v| v.as_f64())? as f32;
|
||||
let height = js_sys::Reflect::get(&vv, &"height".into())
|
||||
.ok()
|
||||
.and_then(|v| v.as_f64())? as f32;
|
||||
(width > 0.0 && height > 0.0).then_some((width, height))
|
||||
}
|
||||
|
||||
/// Listens for visual viewport changes (e.g., soft keyboard appearing) on mobile.
|
||||
pub(crate) fn setup_visual_viewport_resize_listener(
|
||||
event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>,
|
||||
) {
|
||||
if !mobile_detection::is_mobile_device() {
|
||||
return;
|
||||
}
|
||||
|
||||
let window = gloo::utils::window();
|
||||
let visual_viewport = js_sys::Reflect::get(&window, &"visualViewport".into())
|
||||
.ok()
|
||||
.and_then(|v| v.dyn_into::<web_sys::EventTarget>().ok());
|
||||
|
||||
let Some(visual_viewport) = visual_viewport else {
|
||||
log::warn!("Visual viewport API not available");
|
||||
return;
|
||||
};
|
||||
|
||||
// Fire once immediately so the first render uses the correct visual viewport height.
|
||||
if let Some((width, height)) = get_visual_viewport_dimensions() {
|
||||
let _ = event_loop_proxy.send_event(CustomEvent::VisualViewportResized { width, height });
|
||||
}
|
||||
|
||||
EventListener::new(&visual_viewport, "resize", move |_| {
|
||||
if let Some((width, height)) = get_visual_viewport_dimensions() {
|
||||
log::debug!("Visual viewport resized to {}x{}", width, height);
|
||||
let _ =
|
||||
event_loop_proxy.send_event(CustomEvent::VisualViewportResized { width, height });
|
||||
}
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
|
||||
/// Adds an event listener to the main canvas element which calls preventDefault on all important
|
||||
/// events except for those we explicitly want to pass through to the browser.
|
||||
pub(crate) fn add_prevent_default_listener(canvas: &web_sys::HtmlCanvasElement) {
|
||||
// Event types where we unconditionally call prevent_default.
|
||||
let events_types_to_prevent = [
|
||||
"touchstart",
|
||||
"wheel",
|
||||
"contextmenu",
|
||||
"pointerdown",
|
||||
"pointermove",
|
||||
];
|
||||
|
||||
// Keyboard events where we call prevent_default in some cases.
|
||||
let key_events_to_partially_prevent = ["keyup", "keydown"];
|
||||
|
||||
for event_type in events_types_to_prevent.into_iter() {
|
||||
let prevent_default_listener = Box::new(EventListener::new_with_options(
|
||||
canvas,
|
||||
event_type,
|
||||
EventListenerOptions::enable_prevent_default(),
|
||||
move |event| {
|
||||
event.prevent_default();
|
||||
},
|
||||
));
|
||||
|
||||
// We want this to live for the lifetime of the page and we're never going to need to
|
||||
// interact with it again, so we leak it so it can live forever.
|
||||
Box::leak(prevent_default_listener);
|
||||
}
|
||||
|
||||
for event_type in key_events_to_partially_prevent.into_iter() {
|
||||
let prevent_default_listener = Box::new(EventListener::new_with_options(
|
||||
canvas,
|
||||
event_type,
|
||||
EventListenerOptions::enable_prevent_default(),
|
||||
move |event| {
|
||||
let event = event.dyn_ref::<web_sys::KeyboardEvent>().unwrap_throw();
|
||||
let keystroke = Keystroke {
|
||||
ctrl: event.ctrl_key(),
|
||||
alt: event.alt_key(),
|
||||
shift: event.shift_key(),
|
||||
cmd: event.meta_key(), // The browser's 'meta' corresponds to our 'command'.
|
||||
meta: false,
|
||||
key: event.key(),
|
||||
};
|
||||
|
||||
let allow_default_event = KEYS_TO_IGNORE.contains(&keystroke);
|
||||
if !allow_default_event {
|
||||
event.prevent_default();
|
||||
}
|
||||
},
|
||||
));
|
||||
Box::leak(prevent_default_listener);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let Some(data) = event.clipboard_data() else {
|
||||
log::warn!("Received paste event without clipboard data.");
|
||||
return;
|
||||
};
|
||||
|
||||
let content = crate::clipboard::ClipboardContent {
|
||||
plain_text: data.get_data("text").unwrap_or_default(),
|
||||
html: data
|
||||
.get_data("text/html")
|
||||
.ok()
|
||||
.and_then(|s| (!s.is_empty()).then_some(s)), // Set this to None if the html data is empty
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let _ = event_loop_proxy.send_event(CustomEvent::Clipboard(
|
||||
crate::windowing::winit::app::ClipboardEvent::Paste(content),
|
||||
));
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
|
||||
pub(crate) fn add_network_connection_listener(
|
||||
event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>,
|
||||
) {
|
||||
let event_loop_proxy_clone = event_loop_proxy.clone();
|
||||
|
||||
EventListener::new(&gloo::utils::window(), "offline", move |_event| {
|
||||
let _ = event_loop_proxy_clone
|
||||
.send_event(crate::windowing::winit::app::CustomEvent::InternetDisconnected);
|
||||
})
|
||||
.forget();
|
||||
|
||||
EventListener::new(&gloo::utils::window(), "online", move |_event| {
|
||||
let _ = event_loop_proxy
|
||||
.send_event(crate::windowing::winit::app::CustomEvent::InternetConnected);
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
|
||||
pub(crate) fn add_system_theme_listener(
|
||||
event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>,
|
||||
) {
|
||||
// This could alternatively be written as a listener on "(prefers-color-scheme: light)".
|
||||
if let Ok(Some(media_query_list)) =
|
||||
gloo::utils::window().match_media("(prefers-color-scheme: dark)")
|
||||
{
|
||||
EventListener::new(&media_query_list, "change", move |_event| {
|
||||
let _ = event_loop_proxy
|
||||
.send_event(crate::windowing::winit::app::CustomEvent::SystemThemeChanged);
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Soft keyboard support for mobile WASM.
|
||||
//!
|
||||
//! On mobile browsers, the soft keyboard only appears when a native HTML input element
|
||||
//! is focused. This module provides utilities to manage a hidden input element to
|
||||
//! trigger the soft keyboard when needed.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! - `SoftKeyboardManager`: Coordinates the hidden input element and keyboard state
|
||||
//! - Mobile detection utilities are in the `mobile_detection` submodule
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
use super::hidden_input::{HiddenInput, HiddenInputEvent};
|
||||
|
||||
// ============================================================================
|
||||
// Soft Keyboard State
|
||||
// ============================================================================
|
||||
|
||||
/// Represents the visibility state of the soft keyboard.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SoftKeyboardState {
|
||||
/// The soft keyboard is hidden.
|
||||
#[default]
|
||||
Hidden,
|
||||
/// The soft keyboard is visible (or should be shown).
|
||||
Visible,
|
||||
}
|
||||
|
||||
impl SoftKeyboardState {
|
||||
/// Returns true if the keyboard should be visible.
|
||||
pub fn is_visible(&self) -> bool {
|
||||
matches!(self, Self::Visible)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a HiddenInputEvent to a SoftKeyboardInput.
|
||||
fn map_hidden_input_event(event: HiddenInputEvent) -> Option<SoftKeyboardInput> {
|
||||
match event {
|
||||
HiddenInputEvent::InsertText { text } => Some(SoftKeyboardInput::TextInserted(text)),
|
||||
HiddenInputEvent::Backspace | HiddenInputEvent::Delete => {
|
||||
Some(SoftKeyboardInput::Backspace)
|
||||
}
|
||||
HiddenInputEvent::Blur => Some(SoftKeyboardInput::KeyboardDismissed),
|
||||
HiddenInputEvent::KeyDown { key } => Some(SoftKeyboardInput::KeyDown(key)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Soft Keyboard Manager
|
||||
// ============================================================================
|
||||
|
||||
/// Callback type for soft keyboard input events.
|
||||
/// The callback receives the processed input event.
|
||||
pub type SoftKeyboardInputCallback = Box<dyn FnMut(SoftKeyboardInput)>;
|
||||
|
||||
/// Processed input from the soft keyboard.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SoftKeyboardInput {
|
||||
/// Text was inserted.
|
||||
TextInserted(String),
|
||||
/// Backspace was pressed.
|
||||
Backspace,
|
||||
/// The keyboard was dismissed externally (e.g., iOS "Done" button).
|
||||
KeyboardDismissed,
|
||||
/// A special key was pressed (e.g., Enter).
|
||||
KeyDown(String),
|
||||
}
|
||||
|
||||
/// Manages the soft keyboard for mobile WASM.
|
||||
///
|
||||
/// This struct coordinates:
|
||||
/// - The hidden input element that triggers the keyboard
|
||||
/// - The current keyboard state (visible/hidden)
|
||||
/// - Processing input events and forwarding them to the app
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Create the manager (only on mobile)
|
||||
/// if mobile_detection::is_mobile_device() {
|
||||
/// let manager = SoftKeyboardManager::new(|input| {
|
||||
/// // Handle input from soft keyboard
|
||||
/// })?;
|
||||
///
|
||||
/// // Show keyboard when text input is focused
|
||||
/// manager.show_keyboard();
|
||||
///
|
||||
/// // Hide keyboard when text input is blurred
|
||||
/// manager.hide_keyboard();
|
||||
/// }
|
||||
/// ```
|
||||
pub struct SoftKeyboardManager {
|
||||
hidden_input: HiddenInput,
|
||||
state: RefCell<SoftKeyboardState>,
|
||||
}
|
||||
|
||||
impl SoftKeyboardManager {
|
||||
/// Creates a new soft keyboard manager.
|
||||
///
|
||||
/// This creates the hidden input element and sets up event forwarding.
|
||||
/// Should only be called on mobile devices (check `is_mobile_device()` first).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `on_input` - Callback invoked when the user types on the soft keyboard.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the hidden input element cannot be created.
|
||||
pub fn new(on_input: SoftKeyboardInputCallback) -> Result<Rc<Self>, JsValue> {
|
||||
let on_input = RefCell::new(on_input);
|
||||
|
||||
// Create a callback that processes hidden input events and forwards them
|
||||
let callback: super::hidden_input::InputCallback =
|
||||
Rc::new(RefCell::new(move |event: HiddenInputEvent| {
|
||||
if let Some(input) = map_hidden_input_event(event) {
|
||||
on_input.borrow_mut()(input);
|
||||
}
|
||||
}));
|
||||
|
||||
let hidden_input = HiddenInput::new(callback)?;
|
||||
|
||||
Ok(Rc::new(Self {
|
||||
hidden_input,
|
||||
state: RefCell::new(SoftKeyboardState::Hidden),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Shows the soft keyboard by focusing the hidden input.
|
||||
///
|
||||
/// This should be called when a text input in the app gains focus.
|
||||
pub fn show_keyboard(&self) {
|
||||
// Always call focus() - the browser handles redundant calls gracefully.
|
||||
// We don't rely on our internal state because the user can dismiss the keyboard
|
||||
// via browser controls (e.g., "Done" button), which doesn't update our state.
|
||||
|
||||
if let Err(e) = self.hidden_input.focus() {
|
||||
log::warn!("Failed to focus hidden input for soft keyboard: {:?}", e);
|
||||
}
|
||||
*self.state.borrow_mut() = SoftKeyboardState::Visible;
|
||||
}
|
||||
|
||||
/// Hides the soft keyboard by blurring the hidden input.
|
||||
///
|
||||
/// For canvas-based apps, this must be called explicitly when the user taps
|
||||
/// outside a text input area, since the browser can't detect "outside" taps
|
||||
/// when everything renders to a single canvas element.
|
||||
pub fn hide_keyboard(&self) {
|
||||
if let Err(e) = self.hidden_input.blur() {
|
||||
log::warn!("Failed to blur hidden input for soft keyboard: {:?}", e);
|
||||
}
|
||||
*self.state.borrow_mut() = SoftKeyboardState::Hidden;
|
||||
}
|
||||
|
||||
/// Returns the current keyboard state.
|
||||
pub fn state(&self) -> SoftKeyboardState {
|
||||
*self.state.borrow()
|
||||
}
|
||||
|
||||
/// Returns whether the soft keyboard is currently visible.
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.state.borrow().is_visible()
|
||||
}
|
||||
|
||||
/// Returns whether the hidden input element currently has focus.
|
||||
///
|
||||
/// This is used to detect when browser focus events are due to the soft keyboard
|
||||
/// rather than the user actually switching away from the window.
|
||||
pub fn has_focus(&self) -> bool {
|
||||
self.hidden_input.has_focus()
|
||||
}
|
||||
|
||||
/// Resets the hidden input to its sentinel state.
|
||||
///
|
||||
/// Sets the value to a single space and positions the cursor after it.
|
||||
/// This is automatically called on focus and after every input event,
|
||||
/// but can be called manually if needed.
|
||||
pub fn reset_input(&self) {
|
||||
self.hidden_input.reset_input();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "soft_keyboard_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,49 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_map_insert_text() {
|
||||
let event = HiddenInputEvent::InsertText {
|
||||
text: "hello".to_string(),
|
||||
};
|
||||
let result = map_hidden_input_event(event);
|
||||
assert!(matches!(result, Some(SoftKeyboardInput::TextInserted(s)) if s == "hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_backspace() {
|
||||
let event = HiddenInputEvent::Backspace;
|
||||
let result = map_hidden_input_event(event);
|
||||
assert!(matches!(result, Some(SoftKeyboardInput::Backspace)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_delete() {
|
||||
let event = HiddenInputEvent::Delete;
|
||||
let result = map_hidden_input_event(event);
|
||||
assert!(matches!(result, Some(SoftKeyboardInput::Backspace)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_blur() {
|
||||
let event = HiddenInputEvent::Blur;
|
||||
let result = map_hidden_input_event(event);
|
||||
assert!(matches!(result, Some(SoftKeyboardInput::KeyboardDismissed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_keydown_enter() {
|
||||
let event = HiddenInputEvent::KeyDown {
|
||||
key: "Enter".to_string(),
|
||||
};
|
||||
let result = map_hidden_input_event(event);
|
||||
assert!(matches!(result, Some(SoftKeyboardInput::KeyDown(key)) if key == "Enter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_unicode_insert() {
|
||||
let event = HiddenInputEvent::InsertText {
|
||||
text: "👋🌍".to_string(),
|
||||
};
|
||||
let result = map_hidden_input_event(event);
|
||||
assert!(matches!(result, Some(SoftKeyboardInput::TextInserted(s)) if s == "👋🌍"));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use itertools::Itertools as _;
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
|
||||
// Re-export a couple winit types and modules as the concrete implementations
|
||||
// for Windows.
|
||||
pub use crate::windowing::winit::app::App;
|
||||
|
||||
pub(crate) static DXC_PATH: std::sync::OnceLock<Option<DXCPath>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Path to the DXC DLLs to be used to compile DirectX shaders using DXC.
|
||||
/// See https://github.com/microsoft/DirectXShaderCompiler.
|
||||
#[derive(Debug)]
|
||||
pub struct DXCPath {
|
||||
pub dxc_path: String,
|
||||
pub dxil_path: String,
|
||||
}
|
||||
|
||||
pub trait AppBuilderExt {
|
||||
/// Set the AppUserModel ID, which Windows uses to attribute notifications to
|
||||
/// our correct application.
|
||||
fn set_app_user_model_id(&mut self, app_id: String);
|
||||
|
||||
/// Use DXC (the newer DirectX Shader Compiler) to compile DirectX shaders.
|
||||
/// Using DXC requires the dlls within [`DXCPath`] to be available and shipped
|
||||
/// alongside the application.=
|
||||
fn use_dxc_for_directx_shader_compilation(&mut self, dxc_path: DXCPath);
|
||||
}
|
||||
|
||||
impl AppBuilderExt for super::AppBuilder {
|
||||
fn set_app_user_model_id(&mut self, app_id: String) {
|
||||
let set_id = unsafe { set_app_user_model_id(app_id) };
|
||||
if let Err(err) = set_id {
|
||||
log::error!("Unable to set Windows AppUserModel ID: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn use_dxc_for_directx_shader_compilation(&mut self, dxc_path: DXCPath) {
|
||||
if let Err(e) = DXC_PATH.set(Some(dxc_path)) {
|
||||
log::warn!("Failed to set DXC path {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn set_app_user_model_id(app_id: String) -> Result<(), windows::core::Error> {
|
||||
let wide_string = std::ffi::OsStr::new(&app_id)
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect_vec();
|
||||
windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(windows::core::PCWSTR(
|
||||
wide_string.as_ptr(),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user