Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
+168
View File
@@ -0,0 +1,168 @@
//! A text rasterizer backed by [`font_kit`] that supports rasterizing at subpixel offsets
use std::sync::Arc;
use anyhow::Result;
use dashmap::DashMap;
use font_kit::canvas::{AntialiasingStrategy, Canvas, RasterizationOptions};
use font_kit::font::Font;
use font_kit::hinting::HintingOptions;
use pathfinder_geometry::rect::RectI;
use pathfinder_geometry::transform2d::Transform2F;
use pathfinder_geometry::vector::{vec2i, Vector2F, Vector2I};
use galaxyui_core::fonts::canvas::RasterFormat;
use galaxyui_core::fonts::{
FontId, GlyphId, Properties, RasterizedGlyph, Style, SubpixelAlignment, Weight,
};
use galaxyui_core::rendering;
#[cfg(target_os = "macos")]
use crate::platform::mac::AutoreleasePoolGuard;
/// A simpler rasterizer backed by font-kit.
pub(crate) struct Rasterizer {
fonts: DashMap<FontId, Arc<Font>>,
}
impl Rasterizer {
pub fn new() -> Self {
Self {
fonts: Default::default(),
}
}
pub fn insert(&self, font_id: FontId, font: Arc<Font>) {
self.fonts.insert(font_id, font);
}
pub fn font_for_id(&self, font_id: FontId) -> Arc<Font> {
self.fonts.get(&font_id).expect("Font must exist").clone()
}
pub fn glyph_raster_bounds(
&self,
font_id: FontId,
point_size: f32,
glyph_id: GlyphId,
scale: Vector2F,
glyph_config: &rendering::GlyphConfig,
) -> Result<RectI> {
let raw_raster_bounds = self.font_for_id(font_id).raster_bounds(
glyph_id,
point_size,
Transform2F::from_scale(scale),
HintingOptions::None,
RasterizationOptions {
antialiasing_strategy: AntialiasingStrategy::GrayscaleAa,
use_thin_strokes: glyph_config
.use_thin_strokes
.enabled_for_scale_factor(scale.x()),
},
)?;
if raw_raster_bounds.size() == Vector2I::zero() {
// Don't adjust the size of a glyph with a default size of zero.
return Ok(raw_raster_bounds);
}
// The default raster bounds provided by font-kit sometimes clip pixels
// off of anti-aliased glyphs; add one pixel to the glyph bounds to
// compensate. We only adjust the origin vertically because the extra
// pixel of height changes the baseline; the extra pixel on the right
// side doesn't change positioning (as the origin is on the left edge of
// the glyph).
let fudge_factor = vec2i(1, 1);
let offset = vec2i(0, 1);
Ok(RectI::new(
raw_raster_bounds.origin() - offset,
raw_raster_bounds.size() + fudge_factor,
))
}
#[allow(clippy::too_many_arguments)]
pub 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> {
// On macOS, this function calls into Core Graphics and Core Text
// (`CGBitmapContextCreate` per glyph plus `raster_bounds` reading font
// metadata), each of which leaves transient bookkeeping objects in the
// thread's autorelease pool. Because this is invoked during Metal
// frame rendering on the main thread, hundreds of those objects can
// accumulate between run-loop turns before AppKit's outer pool
// drains. A local pool bounds that peak without relying on the outer
// pool. The guard drains on `Drop`, covering the error paths from `?`
// below and any panics from `font_kit`.
#[cfg(target_os = "macos")]
let _pool = AutoreleasePoolGuard::new();
let bounds =
self.glyph_raster_bounds(font_id, point_size, glyph_id, scale, glyph_config)?;
let mut canvas = Canvas::new(bounds.size(), raster_format_to_font_kit(format));
let base_transform = Transform2F::from_scale(scale).translate(-bounds.origin().to_f32());
let aligned_transform = base_transform.translate(subpixel_alignment.to_offset());
self.font_for_id(font_id).rasterize_glyph(
&mut canvas,
glyph_id,
point_size,
aligned_transform,
HintingOptions::None,
RasterizationOptions {
antialiasing_strategy: AntialiasingStrategy::GrayscaleAa,
use_thin_strokes: glyph_config
.use_thin_strokes
.enabled_for_scale_factor(scale.x()),
},
)?;
Ok(RasterizedGlyph {
canvas: canvas.into(),
// TODO(alokedesai): Properly support colored glyphs on Windows.
is_emoji: self.font_for_id(font_id).is_colored() && !cfg!(windows),
})
}
}
pub fn properties_to_font_kit(properties: Properties) -> font_kit::properties::Properties {
font_kit::properties::Properties {
style: style_to_font_kit(properties.style),
weight: weight_to_font_kit(properties.weight),
stretch: Default::default(),
}
}
fn raster_format_to_font_kit(format: RasterFormat) -> font_kit::canvas::Format {
use font_kit::canvas::Format as FKFormat;
match format {
RasterFormat::Rgba32 => FKFormat::Rgba32,
RasterFormat::Rgb24 => FKFormat::Rgb24,
RasterFormat::A8 => FKFormat::Rgb24,
}
}
fn weight_to_font_kit(weight: Weight) -> font_kit::properties::Weight {
match weight {
Weight::Thin => font_kit::properties::Weight::THIN,
Weight::ExtraLight => font_kit::properties::Weight::EXTRA_LIGHT,
Weight::Light => font_kit::properties::Weight::LIGHT,
Weight::Normal => font_kit::properties::Weight::NORMAL,
Weight::Medium => font_kit::properties::Weight::MEDIUM,
Weight::Semibold => font_kit::properties::Weight::SEMIBOLD,
Weight::Bold => font_kit::properties::Weight::BOLD,
Weight::ExtraBold => font_kit::properties::Weight::EXTRA_BOLD,
Weight::Black => font_kit::properties::Weight::BLACK,
}
}
fn style_to_font_kit(value: Style) -> font_kit::properties::Style {
match value {
Style::Normal => font_kit::properties::Style::Normal,
Style::Italic => font_kit::properties::Style::Italic,
}
}
+15
View File
@@ -0,0 +1,15 @@
#[cfg(native)]
#[cfg_attr(not(macos), allow(dead_code))]
pub mod font_kit;
#[cfg(test)]
#[path = "text_layout_test.rs"]
mod text_layout_tests;
pub use galaxyui_core::fonts::*;
#[cfg(test)]
pub(crate) use text_layout_tests::{collect_glyph_indices, init_fonts};
#[cfg(all(test, target_os = "macos"))]
pub(crate) use text_layout_tests::collect_line_caret_position_starts;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
pub mod fonts;
pub mod platform;
pub mod rendering;
pub mod windowing;
// Re-export everything from the core crate.
pub use galaxyui_core::*;
+146
View File
@@ -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
}
}
+82
View File
@@ -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
)
})
}
+643
View File
@@ -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();
});
}
}
+249
View File
@@ -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,
}
}
+656
View File
@@ -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())
}
}
+239
View File
@@ -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)),
})
}
+356
View File
@@ -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];
}
}
+82
View File
@@ -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);
+552
View File
@@ -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(())
}
+118
View File
@@ -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
+52
View File
@@ -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));
}
+179
View File
@@ -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(),
))
}
@@ -0,0 +1,138 @@
use crate::rendering::atlas::{AllocatedRegion, AllocationError};
use pathfinder_geometry::rect::{RectF, RectI};
use pathfinder_geometry::vector::{vec2f, vec2i, Vector2I};
/// The number of pixels of padding that should be applied between elements
/// in an atlas row.
const HORIZONTAL_PADDING: i32 = 1;
/// The number of pixels of padding that should be applied between rows of
/// elements in the atlas.
const VERTICAL_PADDING: i32 = 1;
/// A naive allocator to determine where items should be inserted into an atlas. Items are packed in
/// by using the Shelf-Next Fit algorithm (as described in
/// <https://blog.roomanna.com/09-25-2015/binpacking-shelf>). Items are fit horizontally in the
/// current open row (aka shelf) until a new element does not fit in that row, at which point a new
/// row for elements are created.
/// Visually, this looks like the following:
///
/// ```text
/// (width, height)
/// ┌─────┬─────┬─────┬─────┬─────┐
/// │ 10 │ │ │ │ │ <- Empty spaces; can be filled while
/// │ │ │ │ │ │ element_height < height - row_baseline
/// ├─────┼─────┼─────┼─────┼─────┤
/// │ 5 │ 6 │ 7 │ 8 │ 9 │
/// │ │ │ │ │ │
/// ├─────┼─────┼─────┼─────┴─────┤ <- Row height is tallest element in row; this is
/// │ 1 │ 2 │ 3 │ 4 │ used as the baseline for the following row.
/// │ │ │ │ │ <- Row considered full when next element doesn't
/// └─────┴─────┴─────┴───────────┘ fit in the row.
/// (0, 0) x->
/// ```
#[derive(Debug)]
pub(crate) struct Allocator {
/// Width of atlas.
width: i32,
/// Height of atlas.
height: i32,
/// Left-most free pixel in a row.
///
/// This is called the extent because it is the upper bound of used pixels
/// in a row.
row_extent: i32,
/// Baseline for elements in the current row.
row_baseline: i32,
/// Tallest element in current row.
///
/// This is used as the advance when end of row is reached.
row_tallest: i32,
}
impl Allocator {
pub fn new(size: usize) -> Self {
Self {
width: size as i32,
height: size as i32,
row_extent: 0,
row_baseline: 0,
row_tallest: 0,
}
}
/// Attempts to allocate space for an item of size `element_size` into the atlas. If allocated,
/// returns an [`AllocatedRegion`] that describes the region of the texture that was allocated.
/// Returns an [`AllocationError`] if the item was unable to be inserted into the atlas.
pub fn insert(&mut self, element_size: Vector2I) -> Result<AllocatedRegion, AllocationError> {
if element_size.x() > self.width || element_size.y() > self.height {
return Err(AllocationError::ItemTooLarge);
}
// If there's not enough room in current row, go onto next one.
if !self.room_in_row(element_size) {
self.advance_row()?;
}
// If there's still not room, there's nothing that can be done here.
if !self.room_in_row(element_size) {
return Err(AllocationError::Full);
}
// There appears to be room; allocate space for the iten.
Ok(self.insert_inner(element_size))
}
/// Allocate space for the item without checking for room.
///
/// Internal function for use once atlas has been checked for space.
fn insert_inner(&mut self, element_size: Vector2I) -> AllocatedRegion {
let offset_y = self.row_baseline;
let offset_x = self.row_extent;
let height = element_size.y();
let width = element_size.x();
// Update Atlas state.
self.row_extent = offset_x + width + HORIZONTAL_PADDING;
if height > self.row_tallest {
self.row_tallest = height;
}
// Generate UV coordinates.
let uv_top = offset_y as f32 / self.height as f32;
let uv_left = offset_x as f32 / self.width as f32;
let uv_height = height as f32 / self.height as f32;
let uv_width = width as f32 / self.width as f32;
AllocatedRegion {
uv_region: RectF::new(vec2f(uv_left, uv_top), vec2f(uv_width, uv_height)),
pixel_region: RectI::new(vec2i(offset_x, offset_y), vec2i(width, height)),
}
}
/// Check if there's room in the current row for given element..
fn room_in_row(&self, element_size: Vector2I) -> bool {
let next_extent = self.row_extent + element_size.x();
let enough_width = next_extent <= self.width;
let enough_height = element_size.y() < (self.height - self.row_baseline);
enough_width && enough_height
}
/// Mark current row as finished and prepare to insert into the next row.
fn advance_row(&mut self) -> Result<(), AllocationError> {
let advance_to = self.row_baseline + self.row_tallest + VERTICAL_PADDING;
if self.height - advance_to <= 0 {
return Err(AllocationError::Full);
}
self.row_baseline = advance_to;
self.row_extent = 0;
self.row_tallest = 0;
Ok(())
}
}
@@ -0,0 +1,67 @@
use crate::rendering::atlas::allocator::Allocator;
use crate::rendering::atlas::{AllocatedRegion, AllocationError};
use anyhow::Result;
use pathfinder_geometry::vector::Vector2I;
/// Manager that is responsible for allocating areas into a series of textures atlases.
pub(crate) struct Manager {
current_allocator: Allocator,
current_texture_id: TextureId,
atlas_size: usize,
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct TextureId(usize);
impl TextureId {
/// Returns the initial [`TextureId`] value to use in a fresh texture atlas
/// cache.
pub fn initial_value() -> Self {
Self(0)
}
/// Returns the next [`TextureId`] value to use after this one.
pub fn next(&self) -> Self {
Self(self.0 + 1)
}
pub fn as_usize(&self) -> usize {
self.0
}
}
/// An offset into a region of a given texture that has been allocated for an item.
#[derive(Copy, Debug, Clone)]
pub(crate) struct TextureOffset {
/// The unique identifier for the texture.
pub texture_id: TextureId,
/// The region of the texture that was allocated.
pub allocated_region: AllocatedRegion,
}
impl Manager {
pub fn new(atlas_size: usize) -> Self {
Self {
current_allocator: Allocator::new(atlas_size),
current_texture_id: TextureId::initial_value(),
atlas_size,
}
}
/// Allocates a region of `size` into a texture. Returns a [`TextureOffset`] denoting the region
/// that was allocated.
pub fn insert(&mut self, size: Vector2I) -> Result<TextureOffset> {
match self.current_allocator.insert(size) {
Ok(allocated_region) => Ok(TextureOffset {
texture_id: self.current_texture_id,
allocated_region,
}),
Err(AllocationError::Full) => {
self.current_texture_id = self.current_texture_id.next();
self.current_allocator = Allocator::new(self.atlas_size);
self.insert(size)
}
Err(insert_error) => Err(insert_error.into()),
}
}
}
@@ -0,0 +1,28 @@
mod allocator;
mod manager;
pub(crate) use manager::{Manager, TextureId};
use pathfinder_geometry::rect::{RectF, RectI};
use thiserror::Error;
/// A region of an atlas that has been allocated.
#[derive(Copy, Debug, Clone)]
pub(crate) struct AllocatedRegion {
/// The region of the atlas that was allocated in UV (texture) coordinates.
pub uv_region: RectF,
/// The region of the atlas that was allocated in screen coordinates.
pub pixel_region: RectI,
}
/// Error that can happen when attempting to allocate an element into the atlas.
#[derive(Error, Debug)]
pub(crate) enum AllocationError {
/// Texture atlas is full.
#[error("Unable to insert; atlas is full")]
Full,
/// The item cannot fit within a single texture.
#[error("Unable to insert; item is too large to fit into atlas")]
ItemTooLarge,
}
@@ -0,0 +1,150 @@
use crate::fonts::{canvas, RasterizedGlyph};
use crate::rendering::atlas::{self, AllocatedRegion, TextureId};
use crate::{fonts::SubpixelAlignment, rendering, scene::GlyphKey};
use anyhow::Result;
use ordered_float::OrderedFloat;
use pathfinder_geometry::rect::RectI;
use pathfinder_geometry::{
rect::RectF,
vector::{Vector2F, Vector2I},
};
use std::collections::HashMap;
const ATLAS_SIZE: usize = 1024;
/// Callback to create a texture at a given size.
type CreateTextureCallback<'a, T> = dyn Fn(usize) -> T + 'a;
/// Callback to insert [`RasterizedGlyph`] at a region identified by [`AllocatedRegion`] into a
/// texture, `T`.
type InsertIntoTextureCallback<'a, T> = dyn Fn(AllocatedRegion, &RasterizedGlyph, &mut T) + 'a;
/// Callback to compute the bounds of a glyph when rasterized.
pub(crate) type GlyphRasterBoundsFn<'a> =
dyn Fn(GlyphKey, Vector2F, &rendering::GlyphConfig) -> Result<RectI> + 'a;
/// Callback to rasterize a glyph.
pub(crate) type RasterizeGlyphFn<'a> = dyn Fn(
GlyphKey,
Vector2F,
SubpixelAlignment,
&rendering::GlyphConfig,
canvas::RasterFormat,
) -> Result<RasterizedGlyph>
+ 'a;
/// A cache that caches glyphs in a texture atlas.
pub struct GlyphCache<Texture> {
textures: Vec<Texture>,
cache: HashMap<GlyphCacheKey, GlyphTextureOffset>,
glyph_config: rendering::GlyphConfig,
atlas_manager: atlas::Manager,
}
#[derive(Hash, PartialEq, Eq)]
struct GlyphCacheKey {
glyph_key: GlyphKey,
scale_factor: OrderedFloat<f32>,
subpixel_alignment: SubpixelAlignment,
}
impl GlyphCacheKey {
fn new(glyph_key: GlyphKey, scale_factor: f32, subpixel_alignment: SubpixelAlignment) -> Self {
GlyphCacheKey {
glyph_key,
scale_factor: scale_factor.into(),
subpixel_alignment,
}
}
}
/// A glyph within a texture atlas.
#[derive(Copy, Debug, Clone)]
pub(crate) struct GlyphTextureOffset {
pub texture_id: TextureId,
pub allocated_region: AllocatedRegion,
pub raster_bounds: RectF,
pub is_emoji: bool,
}
impl<Texture> GlyphCache<Texture> {
pub(crate) fn new(glyph_config: rendering::GlyphConfig) -> Self {
GlyphCache {
textures: Vec::new(),
cache: HashMap::new(),
glyph_config,
atlas_manager: atlas::Manager::new(ATLAS_SIZE),
}
}
pub(crate) fn update_config(&mut self, glyph_config: &rendering::GlyphConfig) {
// If the glyph rendering configuration has changed, blow away the cache
// and replace ourself with a new one.
if *glyph_config != self.glyph_config {
*self = GlyphCache::new(*glyph_config);
}
}
/// Returns the texture identified by [`TextureId`].
pub(crate) fn texture(&self, texture_id: &TextureId) -> Option<&Texture> {
self.textures.get(texture_id.as_usize())
}
/// Returns a [`GlyphTextureOffset`] identified by [`GlyphKey`]. If the [`GlyphKey`] has not
/// been previously cached, the glyph is rasterized and inserted into the texture via the
/// `insert_into_texture` callback. If a new texture needs to be created (since a previous
/// texture is now fill), the `create_texture` callback is called to construct a new texture
/// atlas.
#[allow(clippy::too_many_arguments)]
pub(crate) fn get(
&mut self,
glyph_key: GlyphKey,
scale_factor: f32,
subpixel_alignment: SubpixelAlignment,
create_texture: &CreateTextureCallback<'_, Texture>,
insert_into_texture: &InsertIntoTextureCallback<'_, Texture>,
raster_bounds_fn: &GlyphRasterBoundsFn<'_>,
rasterize_glyph_fn: &RasterizeGlyphFn<'_>,
) -> Result<Option<GlyphTextureOffset>> {
let cache_key = GlyphCacheKey::new(glyph_key, scale_factor, subpixel_alignment);
match self.cache.get(&cache_key) {
None => {
let bounds =
raster_bounds_fn(glyph_key, Vector2F::splat(scale_factor), &self.glyph_config)?;
if bounds.size() == Vector2I::zero() {
return Ok(None);
}
let rasterized_glyph = rasterize_glyph_fn(
glyph_key,
Vector2F::splat(scale_factor),
subpixel_alignment,
&self.glyph_config,
crate::fonts::canvas::RasterFormat::Rgba32,
)?;
let texture_offset = self.atlas_manager.insert(rasterized_glyph.canvas.size)?;
let idx = texture_offset.texture_id.as_usize();
if idx >= self.textures.len() {
self.textures
.resize_with(idx + 1, || create_texture(ATLAS_SIZE));
}
let texture = &mut self.textures[idx];
insert_into_texture(texture_offset.allocated_region, &rasterized_glyph, texture);
let glyph_texture_offset = GlyphTextureOffset {
texture_id: texture_offset.texture_id,
raster_bounds: bounds.to_f32(),
is_emoji: rasterized_glyph.is_emoji,
allocated_region: texture_offset.allocated_region,
};
self.cache.insert(cache_key, glyph_texture_offset);
Ok(Some(glyph_texture_offset))
}
Some(gto) => Ok(Some(*gto)),
}
}
}
+64
View File
@@ -0,0 +1,64 @@
pub(crate) mod atlas;
pub(crate) mod glyph_cache;
#[cfg(wgpu)]
pub mod wgpu;
pub use galaxyui_core::rendering::*;
use galaxyui_core::scene::Dash;
pub(crate) use glyph_cache::{GlyphCache, GlyphRasterBoundsFn, RasterizeGlyphFn};
/// Cache for the result of calling [`is_low_power_gpu_available`], as the
/// check can be expensive.
static LOW_POWER_GPU_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// 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 {
*LOW_POWER_GPU_AVAILABLE.get_or_init(|| {
cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] {
crate::platform::mac::is_low_power_gpu_available()
} else if #[cfg(wgpu)] {
galaxyui_core::r#async::block_on(wgpu::is_low_power_gpu_available())
} else {
false
}
}
})
}
/// Returns the gap length between each dash to ensure that the stroke begins and ends with a full dash,
/// minimizing deviation from the target gap length.
// adapted from Blink dashed border rendering code:
// https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/platform/graphics/stroke_data.cc;l=130-147;drc=51e1b713f6da38219910bf8fb93a81262340bf97
pub(crate) fn get_best_dash_gap(
stroke_length: f32,
Dash {
dash_length,
gap_length,
force_consistent_gap_length,
}: Dash,
) -> f32 {
if force_consistent_gap_length {
return gap_length;
}
// If no space for two dashes and a gap between, return gap length 0 (solid border)
if stroke_length < 2. * dash_length + gap_length {
return 0.;
}
let min_num_dashes = (stroke_length / (dash_length + gap_length)).floor();
let max_num_dashes = min_num_dashes + 1.;
let min_num_gaps = min_num_dashes - 1.;
let max_num_gaps = max_num_dashes - 1.;
let min_gap = (stroke_length - min_num_dashes * dash_length) / min_num_gaps;
let max_gap = (stroke_length - max_num_dashes * dash_length) / max_num_gaps;
if max_gap <= 0. || ((min_gap - gap_length).abs() < (max_gap - gap_length).abs()) {
min_gap
} else {
max_gap
}
}
+246
View File
@@ -0,0 +1,246 @@
pub mod renderer;
mod resources;
mod shader_types;
mod texture_with_bind_group;
use std::sync::{Arc, LazyLock, Mutex};
use wgpu::wgt::WgpuHasDisplayHandle;
pub use renderer::Renderer;
pub use resources::{adapter_has_rendering_offset_bug, Resources};
use crate::platform::GraphicsBackend;
#[cfg(not(target_family = "wasm"))]
use crate::{rendering::GPUPowerPreference, windowing};
static WGPU_INSTANCE: LazyLock<Mutex<Option<Arc<wgpu::Instance>>>> = LazyLock::new(Mutex::default);
/// Drops and recreates the global shared [`wgpu::Instance`].
pub fn reset_wgpu_instance(display_handle: Box<dyn wgpu::wgt::WgpuHasDisplayHandle>) {
// Drop the existing wgpu instance.
{
let mut instance = WGPU_INSTANCE
.lock()
.expect("wgpu instance lock should not be poisoned");
let _ = instance.take();
}
// Create a new one.
init_wgpu_instance(display_handle);
}
/// Initializes the global wgpu instance. This MUST be called before [`get_wgpu_instance()`].
pub fn init_wgpu_instance(display_handle: Box<dyn WgpuHasDisplayHandle>) {
// Check whether DirectComposition should be explicitly disabled on Windows.
let disable_dcomp = std::env::var("GALAXY_USE_DIRECT_COMPOSITION")
.ok()
.is_some_and(|val| {
let val = val.to_lowercase();
val == "0" || val == "false"
});
// A helper function to create a wgpu instance with the appropriate configuration.
let create_instance = move || {
let dx12_shader_compiler = get_dx12_shader_compiler();
Arc::new(wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu_backend_options(),
backend_options: wgpu::BackendOptions {
dx12: wgpu::Dx12BackendOptions {
presentation_system: if disable_dcomp {
wgpu::wgt::Dx12SwapchainKind::DxgiFromHwnd
} else {
wgpu::wgt::Dx12SwapchainKind::DxgiFromVisual
},
shader_compiler: dx12_shader_compiler.unwrap_or(wgpu::Dx12Compiler::Fxc),
..Default::default()
},
..Default::default()
},
flags: wgpu::InstanceFlags::empty(),
memory_budget_thresholds: Default::default(),
display: Some(display_handle),
}))
};
// A helper function for initializing the WGPU_INSTANCE static variable.
//
// If `lock_acquired_tx` is provided, it will be used to signal when the lock has been acquired, allowing
// for asynchronous initialization in a dedicated thread while ensuring that `get_wgpu_instance()` cannot
// race with the initialization.
let init_static_var = |lock_acquired_tx: Option<std::sync::mpsc::Sender<()>>| {
let mut instance_lock_guard = WGPU_INSTANCE
.lock()
.expect("wgpu instance lock should not be poisoned");
if let Some(tx) = lock_acquired_tx {
tx.send(()).expect("Failed to send lock acquired signal");
}
instance_lock_guard.get_or_insert_with(|| {
#[cfg(target_os = "linux")]
{
use crate::windowing::{winit::app::WINDOWING_SYSTEM, WindowingSystem};
// If the user hasn't enabled (and is making use of) native Wayland
// support, due to the fact that we force use of X11 in
// ui/src/windowing/winit/app.rs, we need to make sure wgpu doesn't
// attempt to configure the instance to use Wayland, as that causes
// crashes due to a mismatch between the instance and the window
// handle we pass in later when constructing GPU resources.
if WINDOWING_SYSTEM
.get()
.is_some_and(|windowing_system| *windowing_system == WindowingSystem::X11)
|| std::env::var_os("WAYLAND_DISPLAY").is_none()
{
let old_wayland_display = std::env::var_os("WAYLAND_DISPLAY");
std::env::set_var("WAYLAND_DISPLAY", "");
let instance = create_instance();
match old_wayland_display {
Some(wayland_display) => {
std::env::set_var("WAYLAND_DISPLAY", wayland_display)
}
None => std::env::remove_var("WAYLAND_DISPLAY"),
};
return instance;
}
}
create_instance()
});
};
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
// On wasm, synchronously initialize the wgpu static variable.
init_static_var(None);
} else {
// On other platforms, initialize the wgpu static variable in a separate thread to parallelize
// wgpu instance initialization with other application initialization. We block until we have
// acquired the lock on the instance, ensuring that this function doesn't return until it is
// safe to call `get_wgpu_instance()`.
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
init_static_var(Some(tx));
});
let _ = rx.recv();
}
}
}
/// Helper function to get a [`wgpu::Instance`].
///
/// This should always be used over [`wgpu::Instance::new`] or
/// [`wgpu::Instance::default`] to ensure that configuration is consistent
/// across the app.
fn get_wgpu_instance() -> Arc<wgpu::Instance> {
WGPU_INSTANCE
.lock()
.expect("wgpu instance lock should not be poisoned")
.as_ref()
.expect("wgpu instance should have been initialized")
.clone()
}
/// Returns the set of wgpu backends that we can select from.
fn wgpu_backend_options() -> wgpu::Backends {
wgpu::Backends::from_env().unwrap_or(wgpu::Backends::all())
}
#[cfg(not(target_family = "wasm"))]
pub async fn print_wgpu_adapters(
gpu_power_preference: GPUPowerPreference,
backend_preference: Option<GraphicsBackend>,
windowing_system: Option<windowing::System>,
) {
let instance = get_wgpu_instance();
let backends = wgpu_backend_options();
let adapters = instance.enumerate_adapters(backends).await;
let sorted = resources::sort_adapters(
adapters,
backend_preference.map(to_wgpu_backend),
&gpu_power_preference,
windowing_system,
// This value is only ever true after failing to render frames, which we never attempt when
// running in this mode.
false, /* downrank_non_nvidia_vulkan_adapters */
);
for adapter in sorted {
let info = adapter.get_info();
let device_type = info.device_type;
let device_name = info.name;
let backend = info.backend;
let driver = if info.driver.is_empty() {
"?"
} else {
&info.driver
};
let driver_info = if info.driver_info.is_empty() {
String::new()
} else {
format!(" ({})", info.driver_info)
};
println!("{device_type:?}: {device_name}\n\tBackend: {backend:?}\n\tDriver: {driver}{driver_info}");
}
}
/// 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.
#[cfg(not(target_family = "wasm"))]
pub async fn is_low_power_gpu_available() -> bool {
get_wgpu_instance()
.enumerate_adapters(::wgpu::Backends::all())
.await
.iter()
.any(|adapter| adapter.get_info().device_type == ::wgpu::DeviceType::IntegratedGpu)
}
#[cfg(target_family = "wasm")]
pub async fn is_low_power_gpu_available() -> bool {
// We return false here because we only support WebGL (not WebGPU) on the web and the former
// does not allow configuration of a low or high power GPU.
false
}
#[cfg(windows)]
fn get_dx12_shader_compiler() -> Option<wgpu::Dx12Compiler> {
let dxc_path = crate::platform::windows::DXC_PATH.get()?;
dxc_path
.as_ref()
.map(|dxc_path| wgpu::Dx12Compiler::DynamicDxc {
dxc_path: dxc_path.dxc_path.clone(),
})
}
#[cfg(not(windows))]
fn get_dx12_shader_compiler() -> Option<wgpu::Dx12Compiler> {
None
}
/// Converts a [`wgpu::Backend`] to a [`GraphicsBackend`].
#[cfg_attr(target_os = "macos", expect(dead_code))]
pub(crate) fn from_wgpu_backend(backend: wgpu::Backend) -> GraphicsBackend {
match backend {
wgpu::Backend::Noop => GraphicsBackend::Empty,
wgpu::Backend::Vulkan => GraphicsBackend::Vulkan,
wgpu::Backend::Metal => GraphicsBackend::Metal,
wgpu::Backend::Dx12 => GraphicsBackend::Dx12,
wgpu::Backend::Gl => GraphicsBackend::Gl,
wgpu::Backend::BrowserWebGpu => GraphicsBackend::BrowserWebGpu,
}
}
/// Converts a [`GraphicsBackend`] to a [`wgpu::Backend`].
pub(crate) fn to_wgpu_backend(backend: GraphicsBackend) -> wgpu::Backend {
match backend {
GraphicsBackend::Empty => wgpu::Backend::Noop,
GraphicsBackend::Dx12 => wgpu::Backend::Dx12,
GraphicsBackend::Vulkan => wgpu::Backend::Vulkan,
GraphicsBackend::Gl => wgpu::Backend::Gl,
GraphicsBackend::Metal => wgpu::Backend::Metal,
GraphicsBackend::BrowserWebGpu => wgpu::Backend::BrowserWebGpu,
}
}
@@ -0,0 +1,281 @@
mod frame;
mod glyph;
mod image;
mod rect;
mod util;
use frame::Frame;
use pathfinder_geometry::vector::Vector2F;
use util::with_error_scope;
use galaxyui_core::platform::CapturedFrame;
use wgpu::wgc::{device::DeviceError, present::SurfaceError};
use crate::r#async::block_on;
use crate::rendering::wgpu::Resources;
use crate::rendering::{GlyphConfig, GlyphRasterBoundsFn, RasterizeGlyphFn};
use crate::Scene;
pub use super::resources::{GetSurfaceTextureError, SurfaceConfigureError};
const ENCODER_DESCRIPTOR: wgpu::CommandEncoderDescriptor = wgpu::CommandEncoderDescriptor {
label: Some("Command encoder"),
};
pub struct Renderer {
rect_pipeline: rect::Pipeline,
glyph_pipeline: glyph::Pipeline,
image_pipeline: image::Pipeline,
}
impl Renderer {
pub fn new(resources: &Resources, glyph_config: GlyphConfig) -> Self {
let Resources { device, .. } = resources;
let format = resources.surface_config.borrow().format;
let color_target = wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::all(),
};
let rect_pipeline = rect::Pipeline::new(
resources.uniform_bind_group_layout(),
device,
color_target.clone(),
);
let glyph_pipeline = glyph::Pipeline::new(
resources.uniform_bind_group_layout(),
device,
color_target.clone(),
glyph_config,
);
let image_pipeline =
image::Pipeline::new(resources.uniform_bind_group_layout(), device, color_target);
Self {
rect_pipeline,
glyph_pipeline,
image_pipeline,
}
}
#[allow(clippy::too_many_arguments)]
pub fn render<'a>(
&mut self,
scene: &Scene,
resources: &Resources,
rasterize_glyph_fn: &RasterizeGlyphFn,
glyph_raster_bounds_fn: &GlyphRasterBoundsFn,
window_size: Vector2F,
pre_present_callback: Option<Box<dyn FnOnce() + 'a>>,
capture_callback: Option<Box<dyn FnOnce(CapturedFrame) + Send + 'static>>,
) -> Result<(), Error> {
let Resources { device, queue, .. } = resources;
// Don't initiate the render if we are trying to render into a
// zero-sized window.
if window_size.is_zero() {
return Ok(());
}
let mut ctx = WGPUContext {
resources,
rasterize_glyph_fn,
glyph_raster_bounds_fn,
};
let frame = match with_error_scope(device, || {
Frame::new(
scene,
&mut ctx,
&self.rect_pipeline,
&mut self.glyph_pipeline,
&mut self.image_pipeline,
)
}) {
(_, Some(error)) => return Err(error),
(frame, _) => frame,
};
let surface_texture = resources.get_surface_texture()?;
let mut encoder = device.create_command_encoder(&ENCODER_DESCRIPTOR);
let (_, error) = with_error_scope(device, || {
frame.draw(resources, &mut encoder, &surface_texture);
queue.submit(Some(encoder.finish()));
});
if let Some(callback) = capture_callback {
if let Err(err) =
capture_surface_texture(device, queue, resources, &surface_texture, callback)
{
log::warn!("Frame capture failed: {err}");
}
}
if let Some(callback) = pre_present_callback {
callback();
}
match error {
Some(error) => Err(error),
None => {
// Only present the surface if there were no errors, otherwise
// wgpu will print out an error that we attempted to present a
// texture without submitting any work to the GPU.
match with_error_scope(device, || {
surface_texture.present();
}) {
(_, None) => Ok(()),
(_, Some(error)) => Err(error),
}
}
}
}
}
/// Errors that can occur while rendering a scene.
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Device was lost")]
DeviceLost,
#[error("Failed to acquire surface texture: {0:#}")]
SurfaceError(#[from] GetSurfaceTextureError),
#[error("Failed to configure surface: {0:#}")]
SurfaceConfigureError(#[from] SurfaceConfigureError),
#[error("{0:#}")]
Unknown(#[source] wgpu::Error),
}
impl From<wgpu::Error> for Error {
fn from(value: wgpu::Error) -> Self {
for error in anyhow::Chain::new(&value) {
if let Some(DeviceError::Lost) = error.downcast_ref::<DeviceError>() {
return Error::DeviceLost;
}
// The use of `#[transparent]` for many nested device errors breaks
// error chaining - the call to `source()` gets forwarded to the
// DeviceError::Lost, which returns None (it doesn't wrap an error).
// Ideally, these wrapped errors should use `#[from]` instead, but
// until then, we need to do this to properly catch DeviceError::Lost
// from within a call to present().
if let Some(SurfaceError::Device(DeviceError::Lost)) =
error.downcast_ref::<SurfaceError>()
{
return Error::DeviceLost;
}
}
Error::Unknown(value)
}
}
/// Copies the current surface texture into a `CapturedFrame` and delivers it via `callback`.
///
/// **`callback` is invoked synchronously on the render thread** once the GPU readback
/// completes. It must be lightweight (e.g., move the frame into a shared buffer and return
/// immediately) to avoid stalling frame presentation.
fn capture_surface_texture(
device: &wgpu::Device,
queue: &wgpu::Queue,
resources: &Resources,
surface_texture: &wgpu::SurfaceTexture,
callback: Box<dyn FnOnce(CapturedFrame) + Send + 'static>,
) -> Result<(), String> {
let texture = &surface_texture.texture;
let width = texture.width();
let height = texture.height();
if width == 0 || height == 0 {
return Err(format!("Invalid texture dimensions: {width}x{height}"));
}
let format = resources.surface_config.borrow().format;
let bytes_per_pixel = 4u32;
let unpadded_bytes_per_row = width * bytes_per_pixel;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
let buffer_size = (padded_bytes_per_row * height) as u64;
let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame capture staging buffer"),
size: buffer_size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Frame capture encoder"),
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &staging_buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_bytes_per_row),
rows_per_image: None,
},
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
queue.submit(Some(encoder.finish()));
let buffer_slice = staging_buffer.slice(..);
let (sender, receiver) = std::sync::mpsc::channel();
buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
let _ = sender.send(result);
});
block_on(async {
let _ = device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
});
let map_result = receiver
.recv()
.map_err(|e| format!("Failed to receive map result: {e}"))?
.map_err(|e| format!("Buffer mapping failed: {e}"));
map_result?;
let data = buffer_slice.get_mapped_range();
let mut rgba_data = Vec::with_capacity((width * height * bytes_per_pixel) as usize);
for row in 0..height {
let start = (row * padded_bytes_per_row) as usize;
let end = start + unpadded_bytes_per_row as usize;
rgba_data.extend_from_slice(&data[start..end]);
}
drop(data);
staging_buffer.unmap();
if format == wgpu::TextureFormat::Bgra8Unorm || format == wgpu::TextureFormat::Bgra8UnormSrgb {
for chunk in rgba_data.chunks_exact_mut(4) {
chunk.swap(0, 2);
}
}
callback(CapturedFrame::new(width, height, rgba_data));
Ok(())
}
struct WGPUContext<'a> {
resources: &'a Resources,
rasterize_glyph_fn: &'a RasterizeGlyphFn<'a>,
glyph_raster_bounds_fn: &'a GlyphRasterBoundsFn<'a>,
}
@@ -0,0 +1,194 @@
use crate::rendering::wgpu::renderer::{glyph, image, rect, WGPUContext};
use crate::rendering::wgpu::Resources;
use crate::scene::Layer;
use crate::Scene;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use wgpu::{CommandEncoder, RenderPass, SurfaceTexture};
#[derive(Default)]
struct PerFrameState {
rect: rect::PerFrameState,
glyph: glyph::PerFrameState,
image: image::PerFrameState,
}
/// Struct responsible for rendering a frame by issuing draw calls.
pub(super) struct Frame<'a> {
scene: &'a Scene,
layer_state: Vec<LayerState<'a>>,
per_frame_state: PerFrameState,
rect_pipeline: &'a rect::Pipeline,
glyph_pipeline: &'a mut glyph::Pipeline,
image_pipeline: &'a mut image::Pipeline,
}
impl<'a> Frame<'a> {
pub(super) fn new(
scene: &'a Scene,
ctx: &'a mut WGPUContext<'a>,
rect_pipeline: &'a rect::Pipeline,
glyph_pipeline: &'a mut glyph::Pipeline,
image_pipeline: &'a mut image::Pipeline,
) -> Self {
glyph_pipeline.update_config(&scene.rendering_config().glyphs);
let mut layer_state = vec![];
let mut per_frame_state = PerFrameState::default();
for layer in scene.layers() {
let rect_layer_state =
rect_pipeline.initialize_for_layer(layer, scene, &mut per_frame_state.rect);
let glyph_layer_state =
glyph_pipeline.initialize_for_layer(layer, scene, &mut per_frame_state.glyph, ctx);
let image_layer_state =
image_pipeline.initialize_for_layer(layer, scene, &mut per_frame_state.image, ctx);
layer_state.push(LayerState {
layer,
rect_layer_state,
glyph_layer_state,
image_layer_state,
});
}
rect::Pipeline::finalize_per_frame_state(
&mut per_frame_state.rect,
&ctx.resources.device,
&ctx.resources.device_lost,
);
glyph::Pipeline::finalize_per_frame_state(
&mut per_frame_state.glyph,
&ctx.resources.device,
&ctx.resources.device_lost,
);
image::Pipeline::finalize_per_frame_state(
&mut per_frame_state.image,
&ctx.resources.device,
&ctx.resources.device_lost,
);
Self {
scene,
layer_state,
per_frame_state,
rect_pipeline,
glyph_pipeline,
image_pipeline,
}
}
/// Encodes draw calls into the [`wgpu::CommandEncoder`] to render the [`Scene`]. Callers are
/// responsible for finishing the [`wgpu::CommandEncoder`] and actually presenting the current
/// drawable on the screen.
pub(super) fn draw(
self,
resources: &Resources,
encoder: &mut CommandEncoder,
surface_texture: &SurfaceTexture,
) {
let surface_size = Vector2F::new(
surface_texture.texture.width() as f32,
surface_texture.texture.height() as f32,
);
let view = surface_texture
.texture
.create_view(&wgpu::TextureViewDescriptor {
format: Some(surface_texture.texture.format()),
..Default::default()
});
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
resources.configure_render_pass(&mut render_pass, surface_size);
let device_bounds = RectF::new(Vector2F::zero(), surface_size);
for layer_state in &self.layer_state {
if let Some(bounds) = layer_state.layer.clip_bounds {
// Make sure the scissor rect doesn't extend beyond the boundaries
// of the window.
let bounds = (bounds * self.scene.scale_factor()).intersection(device_bounds);
let Some(intersection) = bounds else {
// The layer's clip bounds don't intersect the window bounds
// at all; we can skip drawing anything in this layer.
continue;
};
Self::set_scissor_rect(&mut render_pass, intersection);
} else {
Self::set_scissor_rect(&mut render_pass, device_bounds);
}
if let Some(rect_layer_state) = &layer_state.rect_layer_state {
self.rect_pipeline.draw(
&mut render_pass,
rect_layer_state,
&self.per_frame_state.rect,
);
}
if let Some(image_layer_state) = &layer_state.image_layer_state {
self.image_pipeline.draw(
&mut render_pass,
image_layer_state,
&self.per_frame_state.image,
);
}
if let Some(glyph_layer_state) = &layer_state.glyph_layer_state {
self.glyph_pipeline.draw(
&mut render_pass,
glyph_layer_state,
&self.per_frame_state.glyph,
);
}
}
}
fn set_scissor_rect(render_pass: &mut RenderPass<'_>, scissor_rect_bounds: RectF) {
// Round the corners independently and derive width/height from those. Rounding origin and
// size independently can produce a rect that extends beyond the surface when the origin
// rounds up and the size also rounds up.
let origin_x = scissor_rect_bounds.origin_x().round() as u32;
let origin_y = scissor_rect_bounds.origin_y().round() as u32;
let max_x = scissor_rect_bounds.max_x().round() as u32;
let max_y = scissor_rect_bounds.max_y().round() as u32;
let width = max_x.saturating_sub(origin_x);
let height = max_y.saturating_sub(origin_y);
// wgpu runtime assertions will fail if a scissor rect is set with a 0 width or height. See
// https://github.com/gfx-rs/wgpu/issues/1750
if height != 0 && width != 0 {
render_pass.set_scissor_rect(origin_x, origin_y, width, height);
}
}
}
impl Drop for Frame<'_> {
fn drop(&mut self) {
// Let the image pipeline know that we've finished the frame so it can
// perform cache cleanup.
self.image_pipeline.end_frame();
}
}
/// State for rendering a given [`Layer`] onto the screen.
struct LayerState<'a> {
layer: &'a Layer,
rect_layer_state: Option<rect::LayerState>,
glyph_layer_state: Option<glyph::LayerState>,
image_layer_state: Option<image::LayerState>,
}
@@ -0,0 +1,348 @@
use crate::fonts::SubpixelAlignment;
use crate::rendering::atlas::TextureId;
use crate::rendering::wgpu::renderer::WGPUContext;
use crate::rendering::wgpu::texture_with_bind_group::TextureWithBindGroup;
use crate::rendering::wgpu::{resources, shader_types};
use crate::rendering::{GlyphCache, GlyphConfig};
use crate::scene::{GlyphFade, Layer};
use crate::Scene;
use pathfinder_geometry::rect::RectF;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{atomic::AtomicBool, Arc};
use wgpu::util::BufferInitDescriptor;
use wgpu::{
BindGroupLayout, BufferUsages, ColorTargetState, Device, FilterMode, RenderPass,
RenderPipeline, Sampler,
};
use super::util::create_buffer_init;
pub(super) struct Pipeline {
glyph_cache: GlyphCache<TextureWithBindGroup>,
render_pipeline: RenderPipeline,
texture_bind_group_layout: BindGroupLayout,
sampler: Sampler,
}
#[derive(Default)]
pub(super) struct PerFrameState {
glyph_data: Vec<shaders::GlyphInstanceData>,
buffer: Option<wgpu::Buffer>,
}
pub(super) struct LayerState {
textures: Vec<PerTextureState>,
}
pub(super) struct PerTextureState {
texture_id: TextureId,
start_offset: usize,
len: usize,
}
impl Pipeline {
pub(super) fn new(
uniform_bind_group_layout: &BindGroupLayout,
device: &Device,
color_target: ColorTargetState,
glyph_config: GlyphConfig,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Glyph Shader"),
source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
"../shaders/glyph_shader.wgsl"
))),
});
let texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
// This should match the filterable field of the
// corresponding Texture entry above.
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
label: Some("texture_bind_group_layout"),
});
let glyph_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Glyph pipeline layout"),
bind_group_layouts: &[
Some(uniform_bind_group_layout),
Some(&texture_bind_group_layout),
],
immediate_size: 0,
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Glyph Render pipeline"),
layout: Some(&glyph_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[
shader_types::Vertex::desc(),
shaders::GlyphInstanceData::desc(),
],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(color_target)],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
// Don't use a pipeline cache. Most desktop GPU drivers have their own internal caches,
// so we are unlikely to get much value out of this for the platforms Warp supports.
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
mag_filter: FilterMode::Linear,
min_filter: FilterMode::Linear,
..Default::default()
});
Self {
glyph_cache: GlyphCache::new(glyph_config),
render_pipeline,
texture_bind_group_layout,
sampler,
}
}
pub(super) fn update_config(&mut self, glyph_config: &GlyphConfig) {
self.glyph_cache.update_config(glyph_config);
}
pub(super) fn initialize_for_layer(
&mut self,
layer: &Layer,
scene: &Scene,
per_frame_state: &mut PerFrameState,
ctx: &WGPUContext,
) -> Option<LayerState> {
if layer.glyphs.is_empty() {
// There are no glyphs to render, exit early.
return None;
}
let scale_factor = scene.scale_factor();
let mut texture_to_glyph: HashMap<TextureId, Vec<shaders::GlyphInstanceData>> =
HashMap::new();
for glyph in &layer.glyphs {
let glyph_position = glyph.position * scale_factor;
let subpixel_alignment = SubpixelAlignment::new(glyph_position);
match self.glyph_cache.get(
glyph.glyph_key,
scene.scale_factor(),
subpixel_alignment,
&|size| {
TextureWithBindGroup::new(
size,
&ctx.resources.device,
&self.texture_bind_group_layout,
&self.sampler,
)
},
&|region, rasterized_glyph, texture| {
texture.insert_glyph_into_texture(
region,
rasterized_glyph,
&ctx.resources.queue,
)
},
ctx.glyph_raster_bounds_fn,
ctx.rasterize_glyph_fn,
) {
Ok(Some(gto)) => {
let (fade_start, fade_end) = match &glyph.fade {
None => (&0.0, &-1.0),
Some(GlyphFade::Horizontal { start, end }) => (start, end),
};
// Adjust the horizontal position by the subpixel alignment
// so that we only shift the glyph over by the amount that
// isn't accounted for in the subpixel-rasterized glyph.
let glyph_position = glyph_position - subpixel_alignment.to_offset();
// Make sure to pass the glyph size in the atlas
// Not the size of the render bounds (which may be smaller)
// If you pass the render bounds as the size, the shader
// will try to sample from a smaller area than the size
// in the atlas, leading to artifacts.
let glyph_instance_data = shaders::GlyphInstanceData::new(
RectF::new(
glyph_position + gto.raster_bounds.origin(),
gto.allocated_region.pixel_region.size().to_f32(),
),
gto.allocated_region.uv_region,
fade_start * scale_factor,
fade_end * scale_factor,
glyph.color,
gto.is_emoji,
);
texture_to_glyph
.entry(gto.texture_id)
.or_default()
.push(glyph_instance_data);
}
Ok(None) => {}
Err(err) => {
log::warn!("Unable to get glyph out of glyph cache: {err:?}, {glyph:?}");
return None;
}
}
}
if texture_to_glyph.is_empty() {
// Early exit if there are no glyphs to render, as it causes a debug assert
// failure in the metal code to create an empty metal buffer.
return None;
}
let mut start_offset = per_frame_state.glyph_data.len();
let per_texture_data = texture_to_glyph
.into_iter()
.map(|(texture_id, mut glyph_instance_data)| {
let len = glyph_instance_data.len();
per_frame_state.glyph_data.append(&mut glyph_instance_data);
let state = PerTextureState {
texture_id,
start_offset,
len,
};
start_offset += len;
state
})
.collect();
Some(LayerState {
textures: per_texture_data,
})
}
pub(super) fn finalize_per_frame_state(
per_frame_state: &mut PerFrameState,
device: &Device,
device_lost: &Arc<AtomicBool>,
) {
per_frame_state.buffer = create_buffer_init(
device,
device_lost,
&BufferInitDescriptor {
label: Some("Glyph instance buffer"),
contents: bytemuck::cast_slice(&per_frame_state.glyph_data),
usage: BufferUsages::VERTEX,
},
)
.ok();
}
pub(super) fn draw<'a>(
&'a self,
render_pass: &mut RenderPass<'a>,
layer_state: &LayerState,
per_frame_state: &'a PerFrameState,
) {
let Some(buffer) = per_frame_state.buffer.as_ref() else {
return;
};
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_vertex_buffer(1, buffer.slice(..));
for per_texture_state in &layer_state.textures {
let texture_with_view = self
.glyph_cache
.texture(&per_texture_state.texture_id)
.expect("texture ID should be in atlas");
render_pass.set_bind_group(1, texture_with_view.bind_group(), &[]);
let end_offset = per_texture_state.start_offset + per_texture_state.len;
render_pass.draw_indexed(
0..resources::quad::INDICES.len() as u32,
0,
per_texture_state.start_offset as u32..end_offset as u32,
);
}
}
}
mod shaders {
use crate::rendering::wgpu::shader_types::{ColorF, Vector4F};
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GlyphInstanceData {
bounds: Vector4F,
uv_bounds: Vector4F,
fade_start: f32,
fade_end: f32,
color: ColorF,
is_emoji: i32,
}
impl GlyphInstanceData {
const ATTRIBS: [wgpu::VertexAttribute; 6] = wgpu::vertex_attr_array![
1 => Float32x4, // Bounds
2 => Float32x4, // UV Bounds
3 => Float32, // Fade Start
4 => Float32, // Fade end
5 => Float32x4, // Color
6 => Sint32, // Is Emoji
];
pub(super) fn new(
bounds: RectF,
uv_left: RectF,
fade_start: f32,
fade_end: f32,
color: ColorU,
is_emoji: bool,
) -> Self {
Self {
bounds: bounds.into(),
uv_bounds: uv_left.into(),
fade_start,
fade_end,
color: color.into(),
is_emoji: is_emoji as i32,
}
}
pub(super) fn desc() -> wgpu::VertexBufferLayout<'static> {
use std::mem;
wgpu::VertexBufferLayout {
array_stride: mem::size_of::<Self>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &Self::ATTRIBS,
}
}
}
}
@@ -0,0 +1,370 @@
use crate::image_cache::StaticImage;
use crate::rendering::texture_cache::{TextureCache, TextureCacheIndex};
use crate::rendering::wgpu::{resources, shader_types};
use crate::scene::Layer;
use crate::Scene;
use std::borrow::Cow;
use std::sync::{atomic::AtomicBool, Arc};
use wgpu::util::BufferInitDescriptor;
use wgpu::{
BindGroup, BindGroupDescriptor, BindGroupLayout, ColorTargetState, Device, Extent3d,
FilterMode, RenderPass, RenderPipeline, Sampler, TextureDescriptor, TextureFormat,
TextureUsages,
};
use self::shaders::{ColorModifier, ImageInstanceData};
use super::util::create_buffer_init;
use super::WGPUContext;
pub(super) struct Pipeline {
render_pipeline: RenderPipeline,
texture_cache: TextureCache<TextureInfo>,
texture_bind_group_layout: BindGroupLayout,
sampler: Sampler,
}
#[derive(Default)]
pub(super) struct PerFrameState {
image_data: Vec<shaders::ImageInstanceData>,
buffer: Option<wgpu::Buffer>,
}
pub(super) struct LayerState {
start_offset: usize,
image_textures: Vec<TextureCacheIndex>,
}
impl Pipeline {
pub(super) fn new(
uniform_bind_group_layout: &BindGroupLayout,
device: &Device,
color_target: ColorTargetState,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Image Shader"),
source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
"../shaders/image_shader.wgsl"
))),
});
let texture_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
// This should match the filterable field of the
// corresponding Texture entry above.
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
label: Some("texture_bind_group_layout"),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Image pipeline layout"),
bind_group_layouts: &[
Some(uniform_bind_group_layout),
Some(&texture_bind_group_layout),
],
immediate_size: 0,
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Image render pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[shader_types::Vertex::desc(), ImageInstanceData::desc()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(color_target)],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
// Don't use a pipeline cache. Most desktop GPU drivers have their own internal caches,
// so we are unlikely to get much value out of this for the platforms Warp supports.
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
mag_filter: FilterMode::Linear,
min_filter: FilterMode::Linear,
..Default::default()
});
Self {
render_pipeline,
texture_cache: TextureCache::new(),
texture_bind_group_layout,
sampler,
}
}
pub(super) fn initialize_for_layer(
&mut self,
layer: &Layer,
scene: &Scene,
per_frame_state: &mut PerFrameState,
ctx: &WGPUContext,
) -> Option<LayerState> {
if layer.images.is_empty() && layer.icons.is_empty() {
return None;
}
let start_offset = per_frame_state.image_data.len();
let mut layer_state = LayerState {
start_offset,
image_textures: Vec::with_capacity(layer.images.len() + layer.icons.len()),
};
let scale_factor = scene.scale_factor();
for image in &layer.images {
let bounds = image.bounds * scale_factor;
let min_dimension = f32::min(bounds.height(), bounds.width());
let corner_radius = crate::rendering::CornerRadius::from_ui_corner_radius(
image.corner_radius,
scale_factor,
min_dimension,
);
per_frame_state.image_data.push(ImageInstanceData::new(
image.bounds * scale_factor,
ColorModifier::Image {
opacity: (image.opacity * 255.) as u8,
},
corner_radius,
));
let (texture_id, _) =
self.texture_cache
.get_or_insert_by_asset(&image.asset, |asset| {
TextureInfo::new(asset, &self.texture_bind_group_layout, &self.sampler, ctx)
});
layer_state.image_textures.push(texture_id);
}
for icon in &layer.icons {
per_frame_state.image_data.push(ImageInstanceData::new(
icon.bounds * scale_factor,
ColorModifier::Icon { color: icon.color },
crate::rendering::CornerRadius::default(),
));
let (texture_id, _) = self
.texture_cache
.get_or_insert_by_asset(&icon.asset, |asset| {
TextureInfo::new(asset, &self.texture_bind_group_layout, &self.sampler, ctx)
});
layer_state.image_textures.push(texture_id);
}
Some(layer_state)
}
pub(super) fn finalize_per_frame_state(
per_frame_state: &mut PerFrameState,
device: &Device,
device_lost: &Arc<AtomicBool>,
) {
per_frame_state.buffer = create_buffer_init(
device,
device_lost,
&BufferInitDescriptor {
label: Some("Image instance buffer"),
contents: bytemuck::cast_slice(&per_frame_state.image_data),
usage: wgpu::BufferUsages::VERTEX,
},
)
.ok();
}
pub(super) fn draw<'a>(
&'a self,
render_pass: &mut RenderPass<'a>,
layer_state: &LayerState,
per_frame_state: &'a PerFrameState,
) {
let Some(buffer) = per_frame_state.buffer.as_ref() else {
return;
};
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_vertex_buffer(1, buffer.slice(..));
for (index, texture_id) in layer_state.image_textures.iter().enumerate() {
let TextureInfo { bind_group, .. } = self
.texture_cache
.get(*texture_id)
.expect("texture should not leave cache between generating layer data and drawing");
render_pass.set_bind_group(1, bind_group, &[]);
let start_offset = layer_state.start_offset + index;
render_pass.draw_indexed(
0..resources::quad::INDICES.len() as u32,
0,
start_offset as u32..(start_offset + 1) as u32,
);
}
}
pub(super) fn end_frame(&mut self) {
self.texture_cache.end_frame();
}
}
/// A structure containing info about a GPU texture from which we can render
/// a particular static image asset.
struct TextureInfo {
/// A handle to the set of resources that are needed to bind the texture
/// in a shader.
bind_group: BindGroup,
}
impl TextureInfo {
fn new(
asset: &Arc<StaticImage>,
bind_group_layout: &BindGroupLayout,
sampler: &Sampler,
ctx: &WGPUContext,
) -> Self {
let texture_size = Extent3d {
width: asset.width(),
height: asset.height(),
depth_or_array_layers: 1,
};
let desc = TextureDescriptor {
label: Some("Image texture"),
size: texture_size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
};
let texture = ctx.resources.device.create_texture(&desc);
let bytes_per_row: u32 = 4 * asset.width();
ctx.resources.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
asset.rgba_bytes(),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: None,
},
texture_size,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = ctx
.resources
.device
.create_bind_group(&BindGroupDescriptor {
layout: bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
label: None,
});
Self { bind_group }
}
}
mod shaders {
use crate::rendering::wgpu::shader_types::{vec4f, ColorF, Vector4F};
use crate::rendering::CornerRadius;
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
/// Icons support overriding the color, whereas images only allow setting the opacity.
pub(super) enum ColorModifier {
Icon { color: ColorU },
Image { opacity: u8 },
}
impl From<ColorModifier> for ColorF {
fn from(color_mod: ColorModifier) -> Self {
match color_mod {
ColorModifier::Icon { color } => color.to_f32().into(),
ColorModifier::Image { opacity } => ColorU::new(0, 0, 0, opacity).to_f32().into(),
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct ImageInstanceData {
bounds: Vector4F,
color: ColorF,
is_icon: u32,
corner_radius: Vector4F,
}
impl ImageInstanceData {
const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
1 => Float32x4, // Bounds
2 => Float32x4, // Color
3 => Uint32, // Boolean, image or icon
4 => Float32x4, // Corner radius
];
pub(super) fn new(
bounds: RectF,
color_modifier: ColorModifier,
corner_radius: CornerRadius,
) -> Self {
Self {
bounds: bounds.into(),
is_icon: matches!(color_modifier, ColorModifier::Icon { .. }).into(),
color: color_modifier.into(),
corner_radius: vec4f(
corner_radius.top_left,
corner_radius.top_right,
corner_radius.bottom_left,
corner_radius.bottom_right,
),
}
}
pub(super) fn desc() -> wgpu::VertexBufferLayout<'static> {
use std::mem;
wgpu::VertexBufferLayout {
array_stride: mem::size_of::<Self>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &Self::ATTRIBS,
}
}
}
}
@@ -0,0 +1,231 @@
use crate::rendering::get_best_dash_gap;
use crate::rendering::wgpu::shader_types::BorderWidth;
use crate::rendering::wgpu::{resources, shader_types};
use crate::scene::Layer;
use crate::Scene;
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::vec2f;
use std::borrow::Cow;
use std::sync::{atomic::AtomicBool, Arc};
use wgpu::util::BufferInitDescriptor;
use wgpu::{BindGroupLayout, ColorTargetState, Device, RenderPass, RenderPipeline};
use super::util::create_buffer_init;
pub(super) struct Pipeline {
render_pipeline: RenderPipeline,
}
#[derive(Default)]
pub(super) struct PerFrameState {
rect_data: Vec<shader_types::RectData>,
buffer: Option<wgpu::Buffer>,
}
pub(super) struct LayerState {
start_offset: usize,
len: usize,
}
impl Pipeline {
pub(super) fn new(
uniform_bind_group_layout: &BindGroupLayout,
device: &Device,
color_target: ColorTargetState,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Rect Shader"),
source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!(
"../shaders/rect_shader.wgsl"
))),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Rect pipeline layout"),
bind_group_layouts: &[Some(uniform_bind_group_layout)],
immediate_size: 0,
});
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Rect render pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[shader_types::Vertex::desc(), shader_types::RectData::desc()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("rect_fs_main"),
targets: &[Some(color_target)],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
// Don't use a pipeline cache. Most desktop GPU drivers have their own internal caches,
// so we are unlikely to get much value out of this for the platforms Warp supports.
cache: None,
});
Self { render_pipeline }
}
pub(super) fn initialize_for_layer(
&self,
layer: &Layer,
scene: &Scene,
per_frame_state: &mut PerFrameState,
) -> Option<LayerState> {
if layer.rects.is_empty() {
// It's a mac assertion error to create an empty metal buffer, so exit early
return None;
}
let scale_factor = scene.scale_factor();
let mut rect_instance_data = Vec::with_capacity(layer.rects.len());
for rect in &layer.rects {
let bounds = rect.bounds * scale_factor;
if let Some(drop_shadow) = rect.drop_shadow {
let sigma = drop_shadow.blur_radius * scale_factor;
let padding = drop_shadow.spread_radius * scale_factor;
let shadow_origin = bounds.origin() + drop_shadow.offset * scale_factor - padding;
let shadow_size = bounds.size() + vec2f(2. * padding, 2. * padding);
let min_dimension = f32::min(shadow_size.x(), shadow_size.y());
let corner_radius = crate::rendering::CornerRadius::from_ui_corner_radius(
rect.corner_radius,
scale_factor,
min_dimension,
);
let bounds = RectF::new(shadow_origin, shadow_size);
let shadow_color = shader_types::Color {
start: vec2f(0., 0.).into(),
start_color: drop_shadow.color.into(),
end: vec2f(1., 0.).into(),
end_color: drop_shadow.color.into(),
};
let border_color = shader_types::Color {
start: vec2f(0., 0.).into(),
start_color: ColorU::transparent_black().into(),
end: vec2f(1., 0.).into(),
end_color: ColorU::transparent_black().into(),
};
rect_instance_data.push(shader_types::RectData::new(
bounds,
shadow_color,
border_color,
corner_radius.clone(),
BorderWidth::default(),
sigma,
padding,
0.,
vec2f(0., 0.),
));
}
let min_dimension = f32::min(bounds.height(), bounds.width());
let corner_radius = crate::rendering::CornerRadius::from_ui_corner_radius(
rect.corner_radius,
scale_factor,
min_dimension,
);
let background_color = shader_types::Color {
start: rect.background.start().into(),
start_color: (rect.background.start_color().into()),
end: rect.background.end().into(),
end_color: (rect.background.end_color().into()),
};
let border_color = shader_types::Color {
start: rect.border.color.start().into(),
start_color: (rect.border.color.start_color().into()),
end: rect.border.color.end().into(),
end_color: (rect.border.color.end_color().into()),
};
let border_width = shader_types::BorderWidth {
top: rect.border.top_width() * scale_factor,
right: rect.border.right_width() * scale_factor,
bottom: rect.border.bottom_width() * scale_factor,
left: rect.border.left_width() * scale_factor,
};
let dash = rect
.border
.dash
.map(|mut dash| {
dash.dash_length *= scale_factor;
dash.gap_length *= scale_factor;
dash
})
.unwrap_or_default();
let horizontal_gap = get_best_dash_gap(bounds.width(), dash);
let vertical_gap = get_best_dash_gap(bounds.height(), dash);
let gap_lengths = vec2f(horizontal_gap, vertical_gap);
let rect_data = shader_types::RectData::new(
bounds,
background_color,
border_color,
corner_radius,
border_width,
0.,
0.,
dash.dash_length,
gap_lengths,
);
rect_instance_data.push(rect_data);
}
let start_offset = per_frame_state.rect_data.len();
let len = rect_instance_data.len();
per_frame_state.rect_data.append(&mut rect_instance_data);
Some(LayerState { start_offset, len })
}
pub(super) fn finalize_per_frame_state(
per_frame_state: &mut PerFrameState,
device: &Device,
device_lost: &Arc<AtomicBool>,
) {
per_frame_state.buffer = create_buffer_init(
device,
device_lost,
&BufferInitDescriptor {
label: Some("Rect instance buffer"),
contents: bytemuck::cast_slice(&per_frame_state.rect_data),
usage: wgpu::BufferUsages::VERTEX,
},
)
.ok();
}
pub(super) fn draw<'a>(
&'a self,
render_pass: &mut RenderPass<'a>,
layer_state: &LayerState,
per_frame_state: &'a PerFrameState,
) {
let Some(buffer) = per_frame_state.buffer.as_ref() else {
return;
};
render_pass.set_pipeline(&self.render_pipeline);
render_pass.set_vertex_buffer(1, buffer.slice(..));
let end_offset = layer_state.start_offset + layer_state.len;
render_pass.draw_indexed(
0..resources::quad::INDICES.len() as u32,
0,
layer_state.start_offset as u32..end_offset as u32,
);
}
}
@@ -0,0 +1,103 @@
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use wgpu::{
util::BufferInitDescriptor, Buffer, BufferAddress, BufferDescriptor, Device,
COPY_BUFFER_ALIGNMENT,
};
use super::Error;
/// Calls the provided function, capturing and returning any validation errors
/// detected by wgpu.
#[must_use]
pub fn with_error_scope<T>(
device: &wgpu::Device,
callback: impl FnOnce() -> T,
) -> (T, Option<Error>) {
let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let ret = callback();
// On native platforms, the future returned by `pop_error_scope` resolves
// immediately. On wasm, it may take longer due to asynchronous browser
// APIs, but it's necessary to wait here to know if it is safe to continue.
let error_future = error_scope.pop();
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
let error = crate::r#async::block_on(error_future);
} else {
use futures::FutureExt;
let error = error_future.now_or_never().expect("always resolves immediately");
}
}
(ret, error.map(Into::into))
}
/// Creates a buffer and initializes it with data, synchronously returning an
/// error if the buffer could not be created successfully.
///
/// This is adapted from [`wgpu::util::DeviceExt::create_buffer_init`], with
/// added logic to check for and return errors from the underlying buffer
/// creation.
pub fn create_buffer_init(
device: &Device,
device_lost: &Arc<AtomicBool>,
descriptor: &BufferInitDescriptor<'_>,
) -> Result<Buffer, super::Error> {
// Skip mapping if the buffer is zero sized
if descriptor.contents.is_empty() {
let wgt_descriptor = BufferDescriptor {
label: descriptor.label,
size: 0,
usage: descriptor.usage,
mapped_at_creation: false,
};
create_buffer(device, &wgt_descriptor)
} else {
let unpadded_size = descriptor.contents.len() as BufferAddress;
// Valid vulkan usage is
// 1. buffer size must be a multiple of COPY_BUFFER_ALIGNMENT.
// 2. buffer size must be greater than 0.
// Therefore we round the value up to the nearest multiple, and ensure it's at least COPY_BUFFER_ALIGNMENT.
let align_mask = COPY_BUFFER_ALIGNMENT - 1;
let padded_size = ((unpadded_size + align_mask) & !align_mask).max(COPY_BUFFER_ALIGNMENT);
let wgt_descriptor = BufferDescriptor {
label: descriptor.label,
size: padded_size,
usage: descriptor.usage,
mapped_at_creation: true,
};
let buffer = create_buffer(device, &wgt_descriptor)?;
if device_lost.load(Ordering::SeqCst) {
return Err(super::Error::DeviceLost);
}
buffer
.slice(..)
.get_mapped_range_mut()
.slice(..unpadded_size as usize)
.copy_from_slice(descriptor.contents);
buffer.unmap();
Ok(buffer)
}
}
/// Creates a buffer using the given device and descriptor, synchronously
/// returning an error if the buffer could not be created successfully.
fn create_buffer(device: &Device, desc: &BufferDescriptor<'_>) -> Result<Buffer, Error> {
let (buffer, error) = with_error_scope(device, || device.create_buffer(desc));
match error {
Some(error) => {
log::warn!("Failed to create wgpu::Buffer: {error:#}");
Err(error)
}
None => Ok(buffer),
}
}
@@ -0,0 +1,917 @@
pub mod quad;
pub mod uniforms;
use std::cell::RefCell;
use std::collections::HashSet;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use crate::rendering::OnGPUDeviceSelected;
use crate::windowing;
use crate::{r#async::block_on, rendering::GPUPowerPreference};
use anyhow::{anyhow, Result};
use itertools::Itertools;
use lazy_static::lazy_static;
use pathfinder_geometry::vector::Vector2F;
use thiserror::Error;
use version_compare::Version;
use galaxyui_core::rendering::{GPUBackend, GPUDeviceInfo, GPUDeviceType};
use wgpu::{
Adapter, Backend, CompositeAlphaMode, CurrentSurfaceTexture, Device, DeviceType, PresentMode,
Queue, Surface, SurfaceConfiguration,
};
/// A mostly-arbitrary value to use as the height/width of a surface when
/// creating a default surface configuration.
///
/// 4 was chosen here because sometimes drivers care that things are a
/// multiple of 2 or 4, so this seemed like a safe choice, while being
/// small enough that any buffers that get allocated are tiny and quick to
/// create and destroy.
const SURFACE_SIZE_FOR_TESTING: u32 = 4;
lazy_static! {
/// The minimum supported driver version for lavapipe, the Vulkan version
/// of Mesa's llvmpipe software renderer.
///
/// While lavapipe is theoretically Vulkan 1.3 compatible starting in version
/// 22.1.2, in practice, Warp windows don't render properly until 24.0.2.
static ref MIN_SUPPORTED_LAVAPIPE_VERSION: Version<'static> = Version::from("24.0.2")
.expect("should not fail to parse version");
/// The minimum supported driver version for Vulkan-backed Intel UHD integrated graphics.
///
/// Some issues we've seen: PLAT-744 and PLAT-599.
/// Mesa changelog mentions a fix for flickering on Intel UHD:
/// https://docs.mesa3d.org/relnotes/21.3.6.html#:~:text=Flickering%20Intel%20Uhd%20620%20Graphics
static ref MIN_SUPPORTED_INTEL_UHD_VERSION: Version<'static> = Version::from("21.3.6")
.expect("should not fail to parse version");
/// Nvidia drivers version 535 have problems with Wayland window managers, e.g. PLAT-667 and
/// PLAT-674.
static ref MIN_SUPPORTED_NVIDIA_VERSION: Version<'static> = Version::from("545")
.expect("should not fail to parse version");
static ref MAX_SUPPORTED_NVIDIA_VERSION_ON_WINDOWS: Version<'static> = Version::from("572")
.expect("should not fail to parse version");
}
/// Set of resources needed to render using wgpu.
pub struct Resources {
pub device: wgpu::Device,
pub device_lost: Arc<AtomicBool>,
pub queue: Queue,
pub adapter: Adapter,
pub surface: Surface<'static>,
pub surface_config: RefCell<SurfaceConfiguration>,
pub supported_backends: Vec<wgpu::Backend>,
uniforms: uniforms::Uniforms,
quad: quad::Resources,
}
impl Resources {
/// Attempts to construct a new instance of [`Resources`] via the provided `window_handle`.
pub fn new(
window_handle: impl Into<wgpu::SurfaceTarget<'static>> + wgpu::rwh::HasDisplayHandle,
gpu_power_preference: GPUPowerPreference,
backend_preference: Option<wgpu::Backend>,
on_gpu_device_selected: &OnGPUDeviceSelected,
initial_surface_size: Vector2F,
downrank_non_nvidia_vulkan_adapters: bool,
) -> Result<Self> {
let windowing_system = window_handle.display_handle()?.as_raw().try_into().ok();
let instance = super::get_wgpu_instance();
let surface = instance.create_surface(window_handle)?;
let backends = super::wgpu_backend_options();
// All of the WGPU initialization functions are asynchronous. For simplicity while
// prototyping, we just use `block_on` to force them to be synchronous.
block_on(async {
let (adapter, device, queue, surface_config, supported_backends) = select_adapter(
&instance,
&surface,
backends,
backend_preference,
gpu_power_preference,
initial_surface_size,
windowing_system,
downrank_non_nvidia_vulkan_adapters,
)
.await
.ok_or_else(|| anyhow!("No usable wgpu adapter was found"))?;
let adapter_info = adapter.get_info();
log::info!(
"Using {:?} {:?} ({}) for rendering new window.",
adapter_info.backend,
adapter_info.device_type,
adapter_info.name,
);
on_gpu_device_selected(device_info_from_adapter_info(adapter_info));
let uniforms = uniforms::Uniforms::new(&device);
let quad = quad::Resources::new(&device);
let device_lost = Arc::new(AtomicBool::new(false));
let device_lost_clone = device_lost.clone();
device.set_device_lost_callback(move |device_lost_reason, message| {
device_lost_clone.store(true, Ordering::SeqCst);
log::warn!("The current device is lost. Reason: {device_lost_reason:?}. Message: {message}")
});
Ok(Self {
device,
device_lost,
queue,
adapter,
surface,
surface_config: surface_config.into(),
supported_backends: supported_backends.into_iter().collect(),
uniforms,
quad,
})
})
}
pub fn uniform_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
self.uniforms.bind_group_layout()
}
pub fn configure_render_pass<'a>(
&'a self,
render_pass: &mut wgpu::RenderPass<'a>,
drawable_size: Vector2F,
) {
self.uniforms
.configure_render_pass(render_pass, drawable_size, self);
self.quad.configure_render_pass(render_pass);
}
/// Updates the size of the underlying surface.
pub fn update_surface_size(&self, size: Vector2F) -> Result<(), SurfaceConfigureError> {
if size.x() > 0. && size.y() > 0. {
let mut surface_config = self.surface_config.borrow_mut();
surface_config.width = size.x() as u32;
surface_config.height = size.y() as u32;
block_on(configure_surface(
&self.surface,
&self.device,
&surface_config,
))
} else {
Ok(())
}
}
/// Gets the next surface texture to render to.
pub fn get_surface_texture(&self) -> Result<wgpu::SurfaceTexture, GetSurfaceTextureError> {
let Resources {
surface,
device,
surface_config,
..
} = self;
let error = match get_surface_texture(surface) {
Ok(texture) => return Ok(texture),
Err(error) => error,
};
log::warn!("Encountered error while getting the next swap chain texture: {error:#}");
match error {
GetSurfaceTextureError::Timeout
| GetSurfaceTextureError::Validation
| GetSurfaceTextureError::Occluded
| GetSurfaceTextureError::ConfigurationError(_) => {
// Skip this frame and hope it resolves itself by the next one.
log::info!("Skipping rendering the current frame...");
Err(error)
}
GetSurfaceTextureError::Lost | GetSurfaceTextureError::Outdated => {
block_on(configure_surface(surface, device, &surface_config.borrow()))
.map_err(GetSurfaceTextureError::ConfigurationError)?;
match get_surface_texture(surface) {
Ok(texture) => {
log::info!("Successfully recreated the swap chain");
Ok(texture)
}
Err(e) => {
log::warn!("Failed to recreate the swap chain: {e:#}");
Err(e)
}
}
}
}
}
}
fn device_info_from_adapter_info(adapter_info: wgpu::AdapterInfo) -> GPUDeviceInfo {
let device_type = match adapter_info.device_type {
DeviceType::Other => GPUDeviceType::Other,
DeviceType::IntegratedGpu => GPUDeviceType::IntegratedGpu,
DeviceType::DiscreteGpu => GPUDeviceType::DiscreteGpu,
DeviceType::VirtualGpu => GPUDeviceType::VirtualGpu,
DeviceType::Cpu => GPUDeviceType::Cpu,
};
let backend = match adapter_info.backend {
Backend::Noop => GPUBackend::Empty,
Backend::Vulkan => GPUBackend::Vulkan,
Backend::Metal => GPUBackend::Metal,
Backend::Dx12 => GPUBackend::Dx12,
Backend::Gl => GPUBackend::Gl,
Backend::BrowserWebGpu => GPUBackend::BrowserWebGpu,
};
GPUDeviceInfo {
device_type,
device_name: adapter_info.name,
driver_name: adapter_info.driver,
driver_info: adapter_info.driver_info,
backend,
}
}
/// Selects the adapter to use to render to the given surface.
///
/// The adapter is selected from the set of adapters that support the given
/// backends, and priority is determined by the power preference.
///
/// This is inspired by the implementation of `request_adapter` in `wgpu_core`:
/// https://github.com/gfx-rs/wgpu/blob/badb3c88ea29acb159d333e2f60b1cc305bbd512/wgpu-core/src/instance.rs#L857
#[allow(clippy::too_many_arguments)]
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
async fn select_adapter(
instance: &wgpu::Instance,
surface: &wgpu::Surface<'static>,
backends: wgpu::Backends,
backend_preference: Option<wgpu::Backend>,
gpu_power_preference: GPUPowerPreference,
initial_surface_size: Vector2F,
windowing_system: Option<windowing::System>,
downrank_non_nvidia_vulkan_adapters: bool,
) -> Option<(
Adapter,
Device,
Queue,
SurfaceConfiguration,
HashSet<wgpu::Backend>,
)> {
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
let power_preference = match gpu_power_preference {
GPUPowerPreference::LowPower => wgpu::PowerPreference::LowPower,
GPUPowerPreference::HighPerformance => wgpu::PowerPreference::HighPerformance,
};
let request_adapter_options = wgpu::RequestAdapterOptions {
power_preference,
force_fallback_adapter: false,
compatible_surface: Some(surface),
};
let adapter = instance.request_adapter(&request_adapter_options).await.ok()?;
let adapters = [adapter].into_iter();
} else {
let adapters = instance
.enumerate_adapters(backends)
.await
.into_iter();
}
}
log::info!("Enabled wgpu backends: {backends:?}");
log::info!("Available wgpu adapters (in priority order):");
let sorted_adapters = sort_adapters(
adapters.collect(),
backend_preference,
&gpu_power_preference,
windowing_system,
downrank_non_nvidia_vulkan_adapters,
);
let adapters = sorted_adapters
// Filter out any unsupported adapters and log information about each one.
.filter(|adapter| is_supported_adapter(adapter, surface))
// While we don't strictly need to collect the iterator into a vector,
// this ensures we log adapter information for all adapters. (Omitting
// this means the iterator is lazily evaluated, and we'll only print
// adapter information up until the point where we find a working one.)
.collect_vec();
let supported_backends = adapters
.iter()
.map(|adapter| adapter.get_info().backend)
.collect::<HashSet<_>>();
for adapter in adapters {
if let Some((device, queue, surface_config)) =
initialize_device(&adapter, surface, initial_surface_size).await
{
return Some((adapter, device, queue, surface_config, supported_backends));
}
}
None
}
/// Sorts adapters according to user preference, stability, and performance.
///
/// All sorts performed here should be stable, ensuring that the relative ordering of previous
/// sorting steps is preserved.
pub(super) fn sort_adapters(
adapters: Vec<wgpu::Adapter>,
backend_preference: Option<wgpu::Backend>,
gpu_power_preference: &GPUPowerPreference,
windowing_system: Option<windowing::System>,
downrank_non_nvidia_vulkan_adapters: bool,
) -> impl Iterator<Item = wgpu::Adapter> {
adapters
.into_iter()
// Sort adapters by backend priority.
.sorted_by_cached_key(|adapter| adapter_backend_sort_func(adapter, backend_preference))
.sorted_by_cached_key(adapter_supported_features)
// Sort adapters based on low/high power preferences.
.sorted_by_cached_key(power_preference_adapter_sort_func(gpu_power_preference))
// Sort adapters that we know have some issues towards the end of the list.
.sorted_by_cached_key(|adapter| {
adapter_stability_sort_func(
adapter,
windowing_system,
downrank_non_nvidia_vulkan_adapters,
)
})
}
/// Returns whether or not a particular adapter is supported and can be used
/// for rendering.
fn is_supported_adapter(adapter: &wgpu::Adapter, surface: &wgpu::Surface) -> bool {
let can_present = adapter.is_surface_supported(surface);
let supported_texture_format = surface
.get_default_config(adapter, SURFACE_SIZE_FOR_TESTING, SURFACE_SIZE_FOR_TESTING)
.map(|config| config.format);
let supported_alpha_modes = surface.get_capabilities(adapter).alpha_modes;
// Log information about the adapter (to assist with debugging).
let info = adapter.get_info();
let device_type = &info.device_type;
let device_name = &info.name;
let backend = &info.backend;
let driver = if info.driver.is_empty() {
"Unknown"
} else {
&info.driver
};
let driver_info = if info.driver_info.is_empty() {
String::new()
} else {
format!(" ({})", info.driver_info)
};
log::info!("{device_type:?}: {device_name}\n\tBackend: {backend:?}\n\tDriver: {driver}{driver_info}\n\tCan present: {can_present}\n\tSupported texture format: {supported_texture_format:?}\n\tSupported alpha mode: {supported_alpha_modes:?}");
can_present && supported_texture_format.is_some()
}
/// Encode levels of preference for graphics adapters based on features they enable. This takes
/// precedence under the "GPU power preference".
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
enum AdapterFeatureSet {
/// No features are hindered by what this adapter supports.
Full = 0,
/// Some non-critical features not supported by the adapter.
MissingMinorFeatures = 1,
}
fn adapter_supported_features(adapter: &Adapter) -> AdapterFeatureSet {
if adapter_has_rendering_offset_bug(&adapter.get_info()) {
log::warn!("Deprioritizing OpenGL-backed Intel UHD adapter");
AdapterFeatureSet::MissingMinorFeatures
} else {
AdapterFeatureSet::Full
}
}
fn is_nvidia_adapter(adapter_info: &wgpu::AdapterInfo) -> bool {
adapter_info.driver == "NVIDIA"
}
fn is_vulkan_nvidia_adapter(adapter_info: &wgpu::AdapterInfo) -> bool {
// Only consider Vulkan adapters using the Nvidia driver.
adapter_info.backend == wgpu::Backend::Vulkan && is_nvidia_adapter(adapter_info)
}
/// Returns whether or not the provided adapter is an unsupported Nvidia driver version for galaxyui
/// to render properly.
fn is_older_nvidia_adapter(adapter_info: &wgpu::AdapterInfo) -> bool {
if !is_vulkan_nvidia_adapter(adapter_info) {
return false;
}
let Some(version) = Version::from(&adapter_info.driver_info) else {
// Log an error so we know this occurred and can improve the logic as-needed.
log::error!(
"Unable to parse Vulkan-backed Nvidia adapter version {:?}; de-prioritizing out of an \
abundance of caution.",
adapter_info.driver_info
);
return true;
};
version < *MIN_SUPPORTED_NVIDIA_VERSION
}
/// Returns whether this adapter is a newer Windows NVIDIA adapter using a non-DX12 backend.
/// On NVIDIA drivers 572 and later, the default value of "auto" for the "Vulkan / OpenGL Present
/// Method" can cause crashes when creating multiple windows, so we downrank it.
fn is_newer_nondx12_nvidia_adapter_on_windows(adapter_info: &wgpu::AdapterInfo) -> bool {
if !cfg!(windows) {
return false;
}
if !is_nvidia_adapter(adapter_info) {
return false;
}
if adapter_info.backend == Backend::Dx12 {
return false;
}
let Some(version) = Version::from(&adapter_info.driver_info) else {
// Log an error so we know this occurred and can improve the logic as-needed.
log::error!(
"Unable to parse Nvidia adapter version {:?} adapter_info.driver_info",
adapter_info.driver_info
);
return false;
};
version >= *MAX_SUPPORTED_NVIDIA_VERSION_ON_WINDOWS
}
/// Returns whether this adapter is the integrated OpenGL driver for Windows running in Parallels.
/// It caused problems with theme background images.
/// https://linear.app/warpdotdev/issue/CORE-3692/background-images-broken-in-parallels
fn is_gl_to_metal_adapter_on_windows_in_parallels(adapter_info: &wgpu::AdapterInfo) -> bool {
cfg!(windows)
&& adapter_info.backend == Backend::Gl
&& adapter_info.device_type == DeviceType::IntegratedGpu
&& adapter_info.driver_info.to_lowercase().contains("metal")
&& adapter_info.name.to_lowercase().starts_with("parallels")
}
/// Returns whether or not the provided adapter is an unsupported Intel UHD Mesa driver version for
/// galaxyui to render properly. Currently, we limit this to "Intel UHD Graphics 620", but we do have
/// some suspicion that more Intel UHD devices are affected, e.g. PLAT-599 has a "Intel(R) UHD
/// Graphics (TGL GT1)" user seeing the exact same issue.
fn is_older_vulkan_intel_uhd_adapter(adapter_info: &wgpu::AdapterInfo) -> bool {
if adapter_info.backend != wgpu::Backend::Vulkan
|| adapter_info.device_type != wgpu::DeviceType::IntegratedGpu
|| !adapter_info.name.contains("Intel(R) HD Graphics 620")
{
return false;
}
mesa_driver_version_is_below_minimum(
&adapter_info.driver_info,
&MIN_SUPPORTED_INTEL_UHD_VERSION,
)
}
/// Returns true if this is:
/// 1) An Intel UHD 620 Graphics device
/// 2) Using the Vulkan backend
/// 3) On Windows
///
/// We have indication that this specific device is unstable on Windows so we ignore it in the
/// hopes that there is a DX12 or GL version of this adapter that is more stable.
fn is_intel_uhd_620_adapter_on_windows_with_vulkan_backend(
adapter_info: &wgpu::AdapterInfo,
) -> bool {
cfg!(windows)
&& adapter_info.backend == Backend::Vulkan
&& adapter_info.device_type == DeviceType::IntegratedGpu
&& (adapter_info.name.contains("Intel(R) UHD Graphics 620")
|| adapter_info.name.contains("Intel(R) HD Graphics 620"))
}
/// Returns whether the given adapter is known to have a rendering offset bug on Windows.
///
/// Certain Intel integrated GPU drivers using the GL backend render the scene at an offset from
/// the window bounds when window decorations are disabled. The offset matches the size of the
/// window decorations (e.g. title bar height). Enabling native window decorations fixes the
/// alignment.
///
/// See: https://github.com/warpdotdev/Warp/issues/6120
pub fn adapter_has_rendering_offset_bug(adapter_info: &wgpu::AdapterInfo) -> bool {
if !cfg!(windows) {
return false;
}
if adapter_info.backend != Backend::Gl || adapter_info.device_type != DeviceType::IntegratedGpu
{
return false;
}
// Known affected Intel integrated GPU models. This list is based on user reports from
// https://github.com/warpdotdev/Warp/issues/6120.
let affected_models = [
"Intel(R) HD Graphics 4000",
"Intel(R) HD Graphics 4400",
"Intel(R) HD Graphics 4600",
"Intel(R) HD Graphics 5500",
"Intel(R) HD Graphics P4600",
"Intel(R) Iris(TM) Pro Graphics 5200",
"Intel(R) Iris(TM) Graphics 6100",
];
affected_models
.iter()
.any(|model| adapter_info.name.contains(model))
}
/// Checks whether the provided adapter info describes a lavapipe
/// (Vulkan llvmpipe) adapter that may not work properly with galaxyui.
fn is_older_lavapipe_adapter(adapter_info: &wgpu::AdapterInfo) -> bool {
// Only consider Vulkan adapters using the llvmpipe driver.
if adapter_info.backend != wgpu::Backend::Vulkan || adapter_info.driver != "llvmpipe" {
return false;
}
mesa_driver_version_is_below_minimum(&adapter_info.driver_info, &MIN_SUPPORTED_LAVAPIPE_VERSION)
}
fn mesa_driver_version_is_below_minimum(info_str: &str, min_version: &Version) -> bool {
let &[name, version, ..] = info_str.splitn(3, ' ').collect_vec().as_slice() else {
// Log an error so we know this occurred and can improve the logic as-needed.
log::error!(
"Encountered Mesa driver info {info_str:?} with an unexpected format! (too few parts)"
);
return false;
};
// Perform an extra check that we parsed the driver info string properly.
if name.trim() != "Mesa" {
// Log an error so we know this occurred and can improve the logic as-needed.
log::error!(
"Encountered Mesa driver info {info_str:?} with an unexpected format! (name != Mesa)"
);
return false;
}
let manifest = version_compare::Manifest {
// We only care about major, minor, and patch versions.
max_depth: Some(3),
..Default::default()
};
let Some(version) = Version::from_manifest(version, &manifest) else {
// Log an error so we know this occurred and can improve the logic as-needed.
log::error!(
"Unable to parse Mesa version {version:?}; de-prioritizing out of an abundance of caution."
);
return true;
};
version < *min_version
}
/// Creates a device and command queue for the given adapter that is guaranteed
/// to be able to create a swapchain for the surface.
async fn initialize_device(
adapter: &Adapter,
surface: &Surface<'static>,
initial_surface_size: Vector2F,
) -> Option<(Device, Queue, SurfaceConfiguration)> {
log::info!(
"Verifying adapter \"{}\" is valid...",
adapter.get_info().name
);
// `Limits::downlevel_webgl2_defaults` gives very conservative defaults. We want to keep these
// limits low in order to make sure we remain compatible with lower-end devices. One exception
// to this is sizes of textures. `using_resolution` increases the size limits on textures. We
// need this because users' displays often exceed the downleveled default limits of 2048px.
// Here, we increase that to the ceiling of what this adapter is capable of.
let mut limits = wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits());
// Set a higher minimum number of variables that can be passed between shader stages.
limits.max_inter_stage_shader_variables = 15;
limits.max_mesh_output_layers = 0;
let (device, queue) = match adapter
.request_device(&wgpu::DeviceDescriptor {
// Use the broadest/most permissive device requirements
// so that we can run on as many machines as possible.
// If we use any WGSL features that aren't included in
// these defaults, we can add specific overrides as needed.
required_limits: limits,
..Default::default()
})
.await
{
Ok(device_and_queue) => device_and_queue,
Err(err) => {
log::warn!("Failed to create a logical device: {err:#}");
return None;
}
};
// Ensure that we're able to create a swapchain before we treat the device
// as valid.
let Some(surface_config) = create_surface_config(adapter, surface, initial_surface_size) else {
log::warn!("Failed to get default surface configuration");
return None;
};
match configure_surface(surface, &device, &surface_config).await {
Ok(_) => Some((device, queue, surface_config)),
Err(err) => {
log::warn!("Failed to create swapchain: {err:#}");
None
}
}
}
/// Returns a priority for an adapter based on backend type, to be used as a
/// sort function.
///
/// This matches the order used by wgpu; see:
/// https://github.com/gfx-rs/wgpu/blob/v0.18/wgpu-core/src/instance.rs#L869-L913
#[cfg(not(windows))]
fn adapter_backend_sort_func(
adapter: &wgpu::Adapter,
backend_preference: Option<wgpu::Backend>,
) -> usize {
let backend = adapter.get_info().backend;
if backend_preference.is_some_and(|pref| pref == backend) {
return 0;
}
match backend {
wgpu::Backend::Vulkan => 1,
wgpu::Backend::Metal => 2,
wgpu::Backend::Dx12 => 3,
wgpu::Backend::BrowserWebGpu => 4,
wgpu::Backend::Gl => 5,
wgpu::Backend::Noop => 6,
}
}
/// Returns a priority for an adapter based on backend type, to be used as a
/// sort function.
///
/// This prioritizes DX12 on Windows which is more reliable. See this issue:
/// https://github.com/gfx-rs/wgpu/issues/2719
#[cfg(windows)]
fn adapter_backend_sort_func(
adapter: &wgpu::Adapter,
backend_preference: Option<wgpu::Backend>,
) -> usize {
let backend = adapter.get_info().backend;
if backend_preference.is_some_and(|pref| pref == backend) {
return 0;
}
match backend {
// On Windows, we prefer DirectX 12 over Vulkan. Given that no other
// platform supports DX12 at all, there's no need to condition this
// ranking on OS.
wgpu::Backend::Dx12 => 1,
wgpu::Backend::Vulkan => 2,
wgpu::Backend::Gl => 3,
wgpu::Backend::Metal => 4,
wgpu::Backend::BrowserWebGpu => 5,
wgpu::Backend::Noop => 6,
}
}
/// Returns a priority for an adapter based on our expectations of its
/// stability.
///
/// This should be used to deprioritize adapters where they _may not_
/// work, but we're not so confident that they are broken that we fully filter
/// them out. Ultimately, if the user only has one adapter, it's better for
/// us to attempt to use it than for us to give up without trying.
fn adapter_stability_sort_func(
adapter: &wgpu::Adapter,
windowing_system: Option<windowing::System>,
downrank_non_nvidia_vulkan_adapters: bool,
) -> AdapterSupport {
let adapter_info = adapter.get_info();
let window_server_is_wayland = matches!(
windowing_system,
Some(windowing::System::Wayland) | Some(windowing::System::X11 { is_x_wayland: true })
);
if downrank_non_nvidia_vulkan_adapters
&& adapter_info.backend == Backend::Vulkan
&& !is_vulkan_nvidia_adapter(&adapter_info)
{
log::info!("Deprioritizing non-NVIDIA Vulkan adapter (the PRIME performance profile is likely enabled)");
return AdapterSupport::Unsupported;
}
if is_intel_uhd_620_adapter_on_windows_with_vulkan_backend(&adapter_info) {
log::warn!("Deprioritizing Vulkan-backed Intel UHD 620 adapter");
return AdapterSupport::SupportedWithIssues;
}
if is_older_vulkan_intel_uhd_adapter(&adapter_info) {
log::warn!(
"Deprioritizing Vulkan-backed Intel UHD adapter due to Mesa < {} (unsupported)",
*MIN_SUPPORTED_INTEL_UHD_VERSION
);
AdapterSupport::SupportedWithIssues
}
// Deprioritize older lavapipe adapters where we have evidence that they are less stable.
else if is_older_lavapipe_adapter(&adapter_info) {
log::warn!(
"Deprioritizing Vulkan-backed llvmpipe adapter due to Mesa < {} (unsupported)",
*MIN_SUPPORTED_LAVAPIPE_VERSION
);
AdapterSupport::Unsupported
// Same with Nvidia drivers, though this is only an issue with a Wayland window server.
} else if window_server_is_wayland && is_older_nvidia_adapter(&adapter_info) {
log::warn!(
"Deprioritizing Vulkan-backed Nvidia adapter due to version < {} (unsupported).\nSee \
the \"Graphics\" secion of our docs here: \
https://docs.warp.dev/help/known-issues#linux-1",
*MIN_SUPPORTED_NVIDIA_VERSION
);
AdapterSupport::Unsupported
} else if is_newer_nondx12_nvidia_adapter_on_windows(&adapter_info) {
log::warn!(
"Deprioritizing non DX12 Nvidia adapter due to version > {} (unsupported). Newer NVIDIA \
drivers can crash if multiple windows are created if the `Vulkan / OpenGL Present Method\
NVIDIA setting is set to `Auto` or `Prefer layered on DXGI Swapchain`.",
*MAX_SUPPORTED_NVIDIA_VERSION_ON_WINDOWS
);
AdapterSupport::SupportedWithIssues
} else if is_gl_to_metal_adapter_on_windows_in_parallels(&adapter_info) {
log::warn!("Deprioritizing integrated OpenGL Windows Parallels adapter.");
AdapterSupport::SupportedWithIssues
} else {
AdapterSupport::Supported
}
}
/// Encode levels of preference for graphics adapters based on application stability. This takes
/// precedence over the "GPU power preference". We've seen varying severities of graphics issues on
/// Linux and Windows.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
enum AdapterSupport {
/// The adapter has no known issues.
Supported = 0,
/// The adapter is somewhat usable, but there have been some problems.
SupportedWithIssues = 1,
/// The adapter is basically not viable. Warpui will either crash or not render.
Unsupported = 2,
}
/// Returns a function that computes the priority for an adapter based on
/// device type, to be used as a sort function.
///
/// This matches the order used by wgpu; see:
/// https://github.com/gfx-rs/wgpu/blob/v0.18/wgpu-core/src/instance.rs#L953-L954
fn power_preference_adapter_sort_func(
pref: &GPUPowerPreference,
) -> impl FnMut(&wgpu::Adapter) -> usize {
match pref {
GPUPowerPreference::LowPower => {
|adapter: &wgpu::Adapter| match adapter.get_info().device_type {
wgpu::DeviceType::IntegratedGpu => 0,
wgpu::DeviceType::DiscreteGpu => 1,
wgpu::DeviceType::Other => 2,
wgpu::DeviceType::VirtualGpu => 3,
wgpu::DeviceType::Cpu => 4,
}
}
GPUPowerPreference::HighPerformance => {
|adapter: &wgpu::Adapter| match adapter.get_info().device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
wgpu::DeviceType::Other => 2,
wgpu::DeviceType::VirtualGpu => 3,
wgpu::DeviceType::Cpu => 4,
}
}
}
}
fn create_surface_config(
adapter: &Adapter,
surface: &Surface,
initial_surface_size: Vector2F,
) -> Option<SurfaceConfiguration> {
let mut config = surface.get_default_config(
adapter,
initial_surface_size.x() as u32,
initial_surface_size.y() as u32,
)?;
// Make sure we're not using an sRGB format.
config.format = config.format.remove_srgb_suffix();
let caps = surface.get_capabilities(adapter);
// COPY_SRC is only needed to support integration test frame capture via
// request_frame_capture. It is not required for normal rendering.
#[cfg(feature = "integration_tests")]
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
config.usage |= wgpu::TextureUsages::COPY_SRC;
}
// Use a non-vsync presentation mode for reduced input delay. This could
// cause visual tearing on present, but we're ok with paying that cost to
// improve responsiveness.
config.present_mode = PresentMode::AutoNoVsync;
// Explicitly request a non-opaque alpha compositing mode, if available.
// Without this, transparent surfaces don't work on native Wayland.
if caps
.alpha_modes
.contains(&CompositeAlphaMode::PostMultiplied)
&& adapter.get_info().backend != wgpu::Backend::Dx12
{
config.alpha_mode = CompositeAlphaMode::PostMultiplied;
} else if caps
.alpha_modes
.contains(&CompositeAlphaMode::PreMultiplied)
{
config.alpha_mode = CompositeAlphaMode::PreMultiplied;
} else if caps.alpha_modes.contains(&CompositeAlphaMode::Inherit) {
config.alpha_mode = CompositeAlphaMode::Inherit;
} else {
config.alpha_mode = CompositeAlphaMode::Auto;
}
Some(config)
}
#[derive(Error, Debug)]
pub enum GetSurfaceTextureError {
#[error("Timeout while getting next surface texture")]
Timeout,
#[error("Window is occluded and cannot be presented to")]
Occluded,
#[error("Surface configuration outdated")]
Outdated,
#[error("Device lost")]
Lost,
#[error("Validation error")]
Validation,
#[error("Failed to configure surface")]
ConfigurationError(SurfaceConfigureError),
}
fn get_surface_texture(
surface: &Surface<'_>,
) -> Result<wgpu::SurfaceTexture, GetSurfaceTextureError> {
let error = match surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture) | CurrentSurfaceTexture::Suboptimal(texture) => {
return Ok(texture)
}
CurrentSurfaceTexture::Timeout => GetSurfaceTextureError::Timeout,
CurrentSurfaceTexture::Occluded => GetSurfaceTextureError::Occluded,
CurrentSurfaceTexture::Outdated => GetSurfaceTextureError::Outdated,
CurrentSurfaceTexture::Lost => GetSurfaceTextureError::Lost,
CurrentSurfaceTexture::Validation => GetSurfaceTextureError::Validation,
};
Err(error)
}
/// Represents an error that occurred when configuring a surface.
#[derive(Error, Debug)]
pub enum SurfaceConfigureError {
#[error("Failed to configure surface: {source:#}\n\nDesired configuration: {config:#?}")]
Error {
/// The underlying error.
#[source]
source: wgpu::Error,
/// The desired configuration.
config: SurfaceConfiguration,
},
}
/// Configures the provided surface.
async fn configure_surface(
surface: &Surface<'_>,
device: &Device,
surface_config: &SurfaceConfiguration,
) -> Result<(), SurfaceConfigureError> {
let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
surface.configure(device, surface_config);
match error_scope.pop().await {
Some(err) => Err(SurfaceConfigureError::Error {
source: err,
config: surface_config.clone(),
}),
None => Ok(()),
}
}
#[cfg(test)]
#[path = "resources_tests.rs"]
mod tests;
@@ -0,0 +1,61 @@
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
Buffer, RenderPass,
};
use crate::rendering::wgpu::shader_types;
/// The vertex buffer slot used for quad vertex data.
const VERTEX_BUFFER_SLOT: u32 = 0;
/// Ordered list of indices in the [`VERTICES`] array to be used as part of an index buffer.
pub(in crate::rendering::wgpu) const INDICES: &[u16] = &[0, 1, 2, 2, 3, 1];
/// List of vertex positions in normalized device coordinates (NDC) that are used when rendering.
/// Similar to our metal renderer, we hardcode a list of vertices for each rect we render, and then
/// determine the actual position of the rect in NDC within the vertex shader.
const VERTICES: &[shader_types::Vertex] = &[
shader_types::Vertex {
position: shader_types::vec2f(0.0, 0.0),
},
shader_types::Vertex {
position: shader_types::vec2f(1.0, 0.0),
},
shader_types::Vertex {
position: shader_types::vec2f(0.0, 1.0),
},
shader_types::Vertex {
position: shader_types::vec2f(1.0, 1.0),
},
];
pub(super) struct Resources {
index_buffer: Buffer,
vertex_buffer: Buffer,
}
impl Resources {
pub fn new(device: &wgpu::Device) -> Self {
let index_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("Quad Index Buffer"),
contents: bytemuck::cast_slice(INDICES),
usage: wgpu::BufferUsages::INDEX,
});
let vertex_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("Quad Vertex Buffer"),
contents: bytemuck::cast_slice(VERTICES),
usage: wgpu::BufferUsages::VERTEX,
});
Self {
index_buffer,
vertex_buffer,
}
}
pub fn configure_render_pass<'a>(&'a self, render_pass: &mut RenderPass<'a>) {
render_pass.set_vertex_buffer(VERTEX_BUFFER_SLOT, self.vertex_buffer.slice(..));
render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
}
}
@@ -0,0 +1,71 @@
use std::mem;
use pathfinder_geometry::vector::Vector2F;
use wgpu::{BindGroup, BindGroupLayout, Buffer};
use crate::rendering::wgpu::{shader_types, Resources};
pub(super) struct Uniforms {
bind_group_layout: BindGroupLayout,
bind_group: BindGroup,
buffer: Buffer,
}
impl Uniforms {
pub fn new(device: &wgpu::Device) -> Self {
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Quad Uniforms Bind Group Layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(
mem::size_of::<shader_types::Uniforms>() as wgpu::BufferAddress,
),
},
count: None,
}],
});
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Uniforms buffer"),
size: mem::size_of::<shader_types::Uniforms>() as wgpu::BufferAddress,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Uniforms Bind Group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
Self {
bind_group_layout,
bind_group,
buffer,
}
}
pub fn bind_group_layout(&self) -> &BindGroupLayout {
&self.bind_group_layout
}
pub fn configure_render_pass<'a>(
&'a self,
render_pass: &mut wgpu::RenderPass<'a>,
drawable_size: Vector2F,
resources: &Resources,
) {
let uniforms = shader_types::Uniforms::new(drawable_size);
resources
.queue
.write_buffer(&self.buffer, 0, bytemuck::cast_slice(&[uniforms]));
render_pass.set_bind_group(0, &self.bind_group, &[]);
}
}
@@ -0,0 +1,126 @@
use super::*;
#[test]
fn test_is_unsupported_llvmpipe_adapter() {
let supported_adapter_info = wgpu::AdapterInfo {
name: "llvmpipe (LLVM 17.0.6, 256 bits)".to_owned(),
// not used
vendor: 0,
// not used
device: 0,
device_type: wgpu::DeviceType::Cpu,
driver: "llvmpipe".to_owned(),
driver_info: "Mesa 24.0.2-arch1.2 (LLVM 17.0.6)".to_owned(),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
};
assert!(!is_older_lavapipe_adapter(&supported_adapter_info));
let unsupported_adapter_info = wgpu::AdapterInfo {
name: "llvmpipe (LLVM 17.0.6, 256 bits)".to_owned(),
// not used
vendor: 0,
// not used
device: 0,
device_type: wgpu::DeviceType::Cpu,
driver: "llvmpipe".to_owned(),
driver_info: "Mesa 23.2.1-1ubuntu3.1~22.04.2 (LLVM 15.0.7)".to_owned(),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
};
assert!(is_older_lavapipe_adapter(&unsupported_adapter_info));
}
#[test]
fn test_is_unsupported_intel_uhd_adapter() {
assert!(is_older_vulkan_intel_uhd_adapter(&wgpu::AdapterInfo {
name: String::from("Intel(R) HD Graphics 620 (KBL GT2)"),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
driver: String::from("Intel open-source Mesa driver"),
driver_info: String::from("Mesa 21.2.6"),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
}));
assert!(!is_older_vulkan_intel_uhd_adapter(&wgpu::AdapterInfo {
name: String::from("Intel(R) HD Graphics 620 (KBL GT2)"),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
driver: String::from("Intel open-source Mesa driver"),
// Version is recent enough
driver_info: String::from("Mesa 23.2.6"),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
}));
assert!(!is_older_vulkan_intel_uhd_adapter(&wgpu::AdapterInfo {
name: String::from("Intel(R) HD Graphics 620 (KBL GT2)"),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
driver: String::from("Intel open-source Mesa driver"),
// Info string is messed up
driver_info: String::from("Mssa 21.2.6"),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
}));
assert!(is_older_vulkan_intel_uhd_adapter(&wgpu::AdapterInfo {
name: String::from("Intel(R) HD Graphics 620 (KBL GT2)"),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
driver: String::from("Intel open-source Mesa driver"),
// Additional info should be ignored
driver_info: String::from("Mesa 21.2.6 foo bar"),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
}));
assert!(!is_older_vulkan_intel_uhd_adapter(&wgpu::AdapterInfo {
name: String::from("Intel(R) HD Graphics 620 (KBL GT2)"),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
driver: String::from("Intel open-source Mesa driver"),
// No version number
driver_info: String::from("Mesa"),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
}));
assert!(is_older_vulkan_intel_uhd_adapter(&wgpu::AdapterInfo {
name: String::from("Intel(R) HD Graphics 620 (KBL GT2)"),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::IntegratedGpu,
driver: String::from("Intel open-source Mesa driver"),
// Nonsense version string
driver_info: String::from("Mesa wtfis&this"),
backend: wgpu::Backend::Vulkan,
device_pci_bus_id: "01:00.0".to_owned(),
subgroup_min_size: wgpu::MINIMUM_SUBGROUP_MIN_SIZE,
subgroup_max_size: wgpu::MAXIMUM_SUBGROUP_MAX_SIZE,
transient_saves_memory: false,
}));
}
@@ -0,0 +1,234 @@
use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct ColorF {
r: f32,
g: f32,
b: f32,
a: f32,
}
impl From<ColorU> for ColorF {
fn from(coloru: ColorU) -> Self {
coloru.to_f32().into()
}
}
impl From<pathfinder_color::ColorF> for ColorF {
fn from(color: pathfinder_color::ColorF) -> Self {
Self {
r: color.r(),
g: color.g(),
b: color.b(),
a: color.a(),
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct Vector2F {
x: f32,
y: f32,
}
pub(super) const fn vec2f(x: f32, y: f32) -> Vector2F {
Vector2F { x, y }
}
impl From<crate::geometry::vector::Vector2F> for Vector2F {
fn from(vec2f: pathfinder_geometry::vector::Vector2F) -> Self {
Self {
x: vec2f.x(),
y: vec2f.y(),
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct Vector4F {
x: f32,
y: f32,
z: f32,
w: f32,
}
pub(super) const fn vec4f(x: f32, y: f32, z: f32, w: f32) -> Vector4F {
Vector4F { x, y, z, w }
}
impl From<pathfinder_geometry::vector::Vector4F> for Vector4F {
fn from(vec4f: pathfinder_geometry::vector::Vector4F) -> Self {
Self {
x: vec4f.x(),
y: vec4f.y(),
z: vec4f.z(),
w: vec4f.w(),
}
}
}
impl From<pathfinder_geometry::rect::RectF> for Vector4F {
fn from(rectf: pathfinder_geometry::rect::RectF) -> Self {
Self {
x: rectf.origin_x(),
y: rectf.origin_y(),
z: rectf.width(),
w: rectf.height(),
}
}
}
/// Vertex position in normalized device coordinates (NDC). We don't need to manage padding of
/// this struct to ensure it is a power of two--WGPU does this for us via the call to
/// `create_buffer_init`.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct Vertex {
pub(super) position: Vector2F,
}
impl Vertex {
const ATTRIBS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0 => Float32x2];
pub(super) fn desc() -> wgpu::VertexBufferLayout<'static> {
use std::mem;
wgpu::VertexBufferLayout {
array_stride: mem::size_of::<Self>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBS,
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct Color {
/// The start location of the background in the range [0,1].
pub(super) start: Vector2F,
pub(super) start_color: ColorF,
/// The end location of the background in the range [0,1].
pub(super) end: Vector2F,
pub(super) end_color: ColorF,
}
#[derive(Default)]
pub(super) struct BorderWidth {
pub(super) top: f32,
pub(super) right: f32,
pub(super) bottom: f32,
pub(super) left: f32,
}
/// Data for a rect that is stored per instance. We don't need to manage padding of
/// this struct to ensure it is a power of two--WGPU does this for us via the call to
/// `create_buffer_init`.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct RectData {
bounds: Vector4F,
background_color: Color,
border_width: Vector4F,
border_color: Color,
corner_radius: Vector4F,
/// The amount of blurring for the shadow, i.e. higher value means more spread out. "Sigma"
/// refers to the term in the formula of the Gaussian distribution, which is used in computing
/// the shadow's shading.
drop_shadow_sigma: f32,
/// The shadow usually spans a larger size than its corresponding rect. This value determines
/// that additional distance in px along each direction.
drop_shadow_padding_factor: f32,
dash_length: f32,
gap_lengths: Vector2F,
}
impl RectData {
const ATTRIBS: [wgpu::VertexAttribute; 13] = wgpu::vertex_attr_array![
// Start at location 1 here because the vertex location occupies location 0.
1 => Float32x4, // Bounds
2 => Float32x2, // Background Start
3 => Float32x4, // Background Start Color
4 => Float32x2, // Background End
5 => Float32x4, // Background End Color
6 => Float32x4, // Border
7 => Float32x2, // Border Start
8 => Float32x4, // Border Start Color
9 => Float32x2, // Border End
10 => Float32x4, // Border End Color
11 => Float32x4, // Corner radius
12 => Float32x2, // Drop Shadow Sigma (Blur Radius) and Padding Factor (Spread Radius)
13 => Float32x3, // Dashed border data: dash length and gap length for x and y dimension
];
#[allow(clippy::too_many_arguments)]
pub fn new(
bounds: RectF,
background_color: Color,
border_color: Color,
corner_radius: crate::rendering::CornerRadius,
border_width: BorderWidth,
drop_shadow_sigma: f32,
drop_shadow_padding_factor: f32,
dash_length: f32,
gap_lengths: pathfinder_geometry::vector::Vector2F,
) -> Self {
Self {
bounds: bounds.into(),
background_color,
border_width: vec4f(
border_width.top,
border_width.right,
border_width.bottom,
border_width.left,
),
border_color,
corner_radius: vec4f(
corner_radius.top_left,
corner_radius.top_right,
corner_radius.bottom_left,
corner_radius.bottom_right,
),
drop_shadow_sigma,
drop_shadow_padding_factor,
dash_length,
gap_lengths: gap_lengths.into(),
}
}
pub(super) fn desc() -> wgpu::VertexBufferLayout<'static> {
use std::mem;
wgpu::VertexBufferLayout {
array_stride: mem::size_of::<Self>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &Self::ATTRIBS,
}
}
}
// Uniform buffer objects need to be 16-byte aligned in WGSL, so enforce
// that constraint here.
//
// See: https://www.w3.org/TR/WGSL/#address-space-layout-constraints
#[repr(C, align(16))]
#[derive(Debug, Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub(super) struct Uniforms {
viewport_size: Vector2F,
// The shader-side paired struct will automatically be padded as necessary,
// so we add any necessary padding bytes here by adjusting the size of this
// byte array.
_struct_padding_bytes: [u8; 8],
}
impl Uniforms {
pub(super) fn new(size: pathfinder_geometry::vector::Vector2F) -> Self {
Self {
viewport_size: size.into(),
_struct_padding_bytes: Default::default(),
}
}
}
@@ -0,0 +1,123 @@
// Brightness-scaled contrast enhancement for glyph alpha masks.
//
// Linear sRGB blending makes light-on-dark text appear too thin because AA fringe
// pixels blend perceptually darker than expected. Dark-on-light text has the opposite
// problem — it already looks heavier than its geometric coverage.
//
// To compensate, we compute the text color's brightness (k) and use it to boost the
// glyph alpha through enhance_contrast(). Brighter text gets a stronger boost;
// dark text is left unchanged.
//
// enhance_contrast() adapted from DWrite_EnhanceContrast in Windows Terminal's DirectWrite shader:
// https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.hlsl
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
fn glyph_color_brightness(color: vec3<f32>) -> f32 {
// REC. 601 luminance coefficients for perceived brightness.
return dot(color, vec3<f32>(0.30, 0.59, 0.11));
}
fn enhance_contrast(alpha: f32, k: f32) -> f32 {
return alpha * (k + 1.0) / (alpha * k + 1.0);
}
struct Uniforms {
viewport_size: vec2<f32>,
// Padding necessary to ensure that the uniforms is 16 bytes. Some wgpu-supported devices (such as webgl) require
// buffer bindings to be a multiple of 16 bytes.
padding: vec2<f32>
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(1) @binding(0) var glyphAtlasTexture: texture_2d<f32>;
@group(1) @binding(1) var glyphAtlasSampler: sampler;
struct GlyphVertexShaderInput {
// The position of the vertex in normalized device coordinates.
@location(0) vertex_position: vec2<f32>,
@location(1) bounds: vec4<f32>,
@location(2) uv_bounds: vec4<f32>,
@location(3) fade_start: f32,
@location(4) fade_end: f32,
@location(5) color: vec4<f32>,
@location(6) is_emoji: i32,
}
struct GlyphVertexShaderOutput {
@builtin(position) position: vec4<f32>,
@location(0) rect_center: vec2<f32>,
@location(1) rect_corner: vec2<f32>,
@location(2) texture_coordinate: vec2<f32>,
@location(3) fade_alpha: f32,
@location(4) color: vec4<f32>,
@location(5) is_emoji: i32,
}
@vertex
fn vs_main(
glyph: GlyphVertexShaderInput,
) -> GlyphVertexShaderOutput {
var out: GlyphVertexShaderOutput;
var origin: vec2<f32> = glyph.bounds.xy;
var size: vec2<f32> = glyph.bounds.zw;
var pixel_pos: vec2<f32> = glyph.vertex_position * size + 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 = vec2(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.
var fade_width: f32 = abs(glyph.fade_end - glyph.fade_start);
var fade_dist: f32 = pixel_pos.x - min(glyph.fade_start, glyph.fade_end);
var fade_alpha: f32;
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;
}
// Convert the position of the item from screen coordinates into normalized device coordinates
var device_pos: vec2<f32> = pixel_pos / uniforms.viewport_size * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
var texture_coordinate: vec2<f32> = glyph.uv_bounds.xy + glyph.vertex_position * glyph.uv_bounds.zw;
out.position = vec4<f32>(device_pos, 0.0, 1.0);
out.rect_corner = size / 2.0;
out.rect_center = 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
fn fs_main(in: GlyphVertexShaderOutput) -> @location(0) vec4<f32> {
// Sample the texture to obtain a color.
var tex_color: vec4<f32> = textureSample(glyphAtlasTexture, glyphAtlasSampler, in.texture_coordinate);
// Use the input color for non-emoji, and the sampled color for emoji.
var color: vec4<f32> = mix(in.color, tex_color, f32(in.is_emoji));
// Scale contrast boost by text brightness:
// light text (white=1) gets full boost; dark text (black=0) gets none.
let k = glyph_color_brightness(color.rgb);
let contrasted = enhance_contrast(tex_color.r, k);
color.a *= max(contrasted, f32(in.is_emoji));
// Apply the fade.
color.a *= saturate(in.fade_alpha);
return color;
}
@@ -0,0 +1,118 @@
struct Uniforms {
viewport_size: vec2<f32>,
// Padding necessary to ensure that the uniforms is 16 bytes. Some wgpu-supported devices (such as webgl) require
// buffer bindings to be a multiple of 16 bytes.
padding: vec2<f32>
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(1) @binding(0) var imageTexture: texture_2d<f32>;
@group(1) @binding(1) var imageSampler: sampler;
struct ImageVertexShaderInput {
// The position of the vertex in normalized device coordinates.
@location(0) vertex_position: vec2<f32>,
@location(1) bounds: vec4<f32>,
@location(2) color: vec4<f32>,
// This field is treated as a boolean to indicate how to interpret the preceding `color` field.
// Icons allow overriding their foreground color, so for icons the whole `color` struct is used.
// For images, only the opacity can be set, and so only the alpha channel would be used.
@location(3) is_icon: u32,
// Corner radius in the order top_left, top_right, bottom_left, bottom_right.
@location(4) corner_radius: vec4<f32>,
}
struct ImageVertexShaderOutput {
@builtin(position) position: vec4<f32>,
@location(0) rect_center: vec2<f32>,
@location(1) rect_corner: vec2<f32>,
@location(2) texture_coordinate: vec2<f32>,
@location(4) color: vec4<f32>,
@location(5) is_icon: u32,
@location(6) corner_radius: vec4<f32>,
}
@vertex
fn vs_main(
image: ImageVertexShaderInput,
) -> ImageVertexShaderOutput {
var out: ImageVertexShaderOutput;
var origin: vec2<f32> = image.bounds.xy;
var size: vec2<f32> = image.bounds.zw;
var pixel_pos: vec2<f32> = image.vertex_position * size + origin;
// Convert the position of the item from screen coordinates into normalized device coordinates
var device_pos: vec2<f32> = pixel_pos / uniforms.viewport_size * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
out.position = vec4<f32>(device_pos, 0.0, 1.0);
// Re-compute size and origin such that they are clipped by the viewport bounds.
var clipped_origin = max(origin, vec2f(0.0, 0.0));
var clipped_size = max(min(origin + size, uniforms.viewport_size) - clipped_origin, vec2f(0.0, 0.0));
out.rect_corner = clipped_size / 2.0;
out.rect_center = clipped_origin + out.rect_corner;
out.texture_coordinate = image.vertex_position;
out.color = image.color;
out.is_icon = image.is_icon;
out.corner_radius = image.corner_radius;
return out;
}
fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, corner_radius: f32) -> f32 {
var p: vec2<f32> = pixel_pos - rect_center;
var q: vec2<f32> = abs(p) - rect_corner + corner_radius;
return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - corner_radius;
}
@fragment
fn fs_main(in: ImageVertexShaderOutput) -> @location(0) vec4<f32> {
// Sample the texture to obtain a color.
var color_sample: vec4<f32> = textureSample(imageTexture, imageSampler, in.texture_coordinate);
var color: vec4<f32>;
if in.is_icon == 0u {
// For an image, use the image color and just adjust opacity.
color = color_sample;
color.a *= in.color.a;
} else {
// There's a naga bug with wgsl --> hlsl conversion where images are always rendered as red.
// We workaround this by first creating an intermediate color where the alpha channel is actually the
// red channel from `color_sample` and then multiplying that by the desired opacity.
var new_color: vec4<f32> = vec4(color_sample.r, color_sample.g, color_sample.b, color_sample.r);
new_color.a *= in.color.a;
// For an icon, use the specified input color.
color = vec4(in.color.r, in.color.g, in.color.b, new_color.a);
}
var outer_corner_radius: f32;
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.w;
} else {
// Bottom left quadrant
outer_corner_radius = in.corner_radius.z;
}
} else {
// Top half
if in.position.x >= in.rect_center.x {
// Top right quadrant
outer_corner_radius = in.corner_radius.y;
} else {
// Top left quadrant
outer_corner_radius = in.corner_radius.x;
}
}
var outer_distance: f32 = 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;
}
@@ -0,0 +1,291 @@
struct Uniforms {
viewport_size: vec2<f32>,
// Padding necessary to ensure that the uniforms is 16 bytes. Some wgpu-supported devices (such as webgl) require
// buffer bindings to be a multiple of 16 bytes.
padding: vec2<f32>
}
const EPSILON: f32 = 0.0000001;
const PI: f32 = 3.141592653589793;
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
struct RectVertexShaderInput {
// The position of the vertex in normalized device coordinates.
@location(0) vertex_position: vec2<f32>,
// Bounds of the item in screen coordinates. Origin is contained in `xy`, size is contained in `zw`.
@location(1) bounds: vec4<f32>,
@location(2) background_start: vec2<f32>,
@location(3) background_start_color: vec4<f32>,
@location(4) background_end: vec2<f32>,
@location(5) background_end_color: vec4<f32>,
// Width of the border in the order top, left, right, bottom.
@location(6) border_width: vec4<f32>,
@location(7) border_start: vec2<f32>,
@location(8) border_start_color: vec4<f32>,
@location(9) border_end: vec2<f32>,
@location(10) border_end_color: vec4<f32>,
// Corner radius in the order top_left, top_right, bottom_left, bottom_right.
@location(11) corner_radius: vec4<f32>,
// The sigma and padding factor values packed into a single vec2. We pack them together in order
// to reduce the total number of attributes, which maxes out at 16. See here:
// https://docs.rs/wgpu/latest/wgpu/struct.Limits.html#structfield.max_vertex_attributes
@location(12) drop_shadow_data: vec2<f32>,
// The length of the dash and the gaps for the x and y dimensions, packed into a single vec3.
@location(13) dashed_border_data: vec3<f32>,
};
struct RectVertexShaderOutput {
@builtin(position) position: vec4<f32>,
@location(0) background_start: vec2<f32>,
@location(1) background_start_color: vec4<f32>,
@location(2) background_end: vec2<f32>,
@location(3) background_end_color: vec4<f32>,
@location(4) border_width: vec4<f32>,
@location(5) border_start: vec2<f32>,
@location(6) border_start_color: vec4<f32>,
@location(7) border_end: vec2<f32>,
@location(8) border_end_color: vec4<f32>,
@location(9) rect_corner: vec2<f32>,
@location(10) rect_center: vec2<f32>,
@location(11) corner_radius: vec4<f32>,
@location(12) drop_shadow_data: vec2<f32>,
@location(13) dashed_border_data: vec3<f32>,
};
@vertex
fn vs_main(
in: RectVertexShaderInput,
) -> RectVertexShaderOutput {
var out: RectVertexShaderOutput;
var origin: vec2<f32> = in.bounds.xy;
var size: vec2<f32> = in.bounds.zw;
var pixel_pos: vec2<f32> = in.vertex_position * size + origin;
// Convert the position of the item from screen coordinates into normalized device coordinates
var ndc_position: vec2<f32> = pixel_pos / uniforms.viewport_size * vec2(2.0, -2.0) + vec2(-1.0, 1.0);
out.position = vec4<f32>(ndc_position, 0.0, 1.0);
out.background_start = in.background_start * size + origin;
out.background_start_color = in.background_start_color;
out.background_end = in.background_end * size + origin;
out.background_end_color = in.background_end_color;
out.border_start = in.border_start * size + origin;
out.border_start_color = in.border_start_color;
out.border_end = in.border_end * size + origin;
out.border_end_color = in.border_end_color;
out.border_width = in.border_width;
out.corner_radius = in.corner_radius;
out.rect_corner = size / 2.;
out.rect_center = origin + out.rect_corner;
out.drop_shadow_data = in.drop_shadow_data;
out.dashed_border_data = in.dashed_border_data;
return out;
}
@fragment
fn rect_fs_main(in: RectVertexShaderOutput) -> @location(0) vec4<f32> {
var background_color: vec4<f32> = derive_color(
in.position.xy,
in.background_start,
in.background_end,
in.background_start_color,
in.background_end_color
);
var border_color: vec4<f32> = derive_color(
in.position.xy,
in.border_start,
in.border_end,
in.border_start_color,
in.border_end_color
);
// 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.
var inner_corner_radius: f32;
var outer_corner_radius: f32;
var border_inner_corner: vec2<f32> = in.rect_corner;
if in.position.y >= in.rect_center.y {
// Bottom half
border_inner_corner.y -= in.border_width.z;
if in.position.x >= in.rect_center.x {
// Bottom right quadrant
border_inner_corner.x -= in.border_width.y;
outer_corner_radius = in.corner_radius.w;
inner_corner_radius = max(0.0, outer_corner_radius - in.border_width.z);
} else {
// Bottom left quadrant
border_inner_corner.x -= in.border_width.w;
outer_corner_radius = in.corner_radius.z;
inner_corner_radius = max(0.0, outer_corner_radius - in.border_width.z);
}
} else {
// Top half
border_inner_corner.y -= in.border_width.x;
if in.position.x >= in.rect_center.x {
// Top right quadrant
border_inner_corner.x -= in.border_width.y;
outer_corner_radius = in.corner_radius.y;
inner_corner_radius = max(0.0, outer_corner_radius - in.border_width.x);
} else {
// Top left quadrant
border_inner_corner.x -= in.border_width.w;
outer_corner_radius = in.corner_radius.x;
inner_corner_radius = max(0.0, outer_corner_radius - in.border_width.x);
}
}
var rect_origin: vec2<f32> = in.rect_center - in.rect_corner;
var outer_distance: f32 = distance_from_rect(in.position.xy, in.rect_center, in.rect_corner, outer_corner_radius);
var inner_distance: f32 = distance_from_rect(in.position.xy, in.rect_center, border_inner_corner, inner_corner_radius);
var drop_shadow_sigma = in.drop_shadow_data.x;
var drop_shadow_padding_factor = in.drop_shadow_data.y;
if drop_shadow_sigma > 0.0 {
var rect_size: vec2<f32> = in.rect_corner * 2.0;
// 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.
var shadowed_rect_origin: vec2<f32> = rect_origin + drop_shadow_padding_factor;
var shadowed_rect_size: vec2<f32> = rect_size - 2.0 * drop_shadow_padding_factor;
background_color.a *= rounded_box_shadow(
shadowed_rect_origin,
shadowed_rect_origin + shadowed_rect_size,
in.position.xy,
drop_shadow_sigma,
outer_corner_radius
);
} else {
// Adjust the opacity of the border color based on where the pixel lies
// between the background and the border_width.
border_color.a *= saturate(inner_distance + 0.5);
// Force the alpha value to 0 (fully transparent) if the pixel is
// outside the border_width.
//
// 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 *= f32(inner_distance > outer_distance);
var rect_bottom_right = in.rect_center + in.rect_corner;
var pos_from_origin = in.position.xy - rect_origin;
// Masks for pixels outside of inner rectangle or on border
var is_horizontal_border = (in.position.y <= rect_origin.y + in.border_width.x) || (in.position.y >= rect_bottom_right.y - in.border_width.z);
var is_vertical_border = (in.position.x <= rect_origin.x + in.border_width.w) || (in.position.x >= rect_bottom_right.x - in.border_width.y);
var dash_length = in.dashed_border_data.x;
var gap_lengths = in.dashed_border_data.yz;
// Get length along the dash and gap segment and determine if pixel is in dash or gap
var length_on_dash_and_gap_segment_x = pos_from_origin.x % (dash_length + gap_lengths.x);
var length_on_dash_and_gap_segment_y = pos_from_origin.y % (dash_length + gap_lengths.y);
var is_horizontal_dash = is_horizontal_border && (length_on_dash_and_gap_segment_x < dash_length);
var is_vertical_dash = is_vertical_border && (length_on_dash_and_gap_segment_y < dash_length);
// Mask out any gaps in the border
border_color.a *= f32(dash_length <= 0.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
var alpha: f32 = border_color.a + background_color.a * (1.0 - border_color.a);
var new_background_color: vec3<f32> = (border_color.rgb * border_color.a + background_color.rgb * background_color.a * (1.0 - border_color.a)) / (alpha + EPSILON);
background_color = vec4(new_background_color, 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. {
background_color.a *= 1.0 - saturate(outer_distance + 0.5);
}
return background_color;
}
fn derive_color(
position: vec2<f32>,
start: vec2<f32>,
end: vec2<f32>,
start_color: vec4<f32>,
end_color: vec4<f32>
) -> vec4<f32> {
var adjusted_end: vec2<f32> = end - start;
var h: f32 = dot(position - start, adjusted_end) / dot(adjusted_end, adjusted_end);
return mix(start_color, end_color, h);
}
// Based on the fragement position and the center of the quad, select one of the 4 radi.
// Order matches CSS border radius attribute:
// radi.x = top-left, radi.y = top-right, radi.z = bottom-right, radi.w = bottom-left
fn select_border_radius(radi: vec4<f32>, position: vec2<f32>, center: vec2<f32>) -> f32 {
var rx = radi.x;
var ry = radi.y;
rx = select(radi.x, radi.y, position.x > center.x);
ry = select(radi.w, radi.z, position.x > center.x);
rx = select(rx, ry, position.y > center.y);
return rx;
}
fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, corner_radius: f32) -> f32 {
var p: vec2<f32> = pixel_pos - rect_center;
var q: vec2<f32> = abs(p) - rect_corner + corner_radius;
return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - corner_radius;
}
// Drop shadow code *heavily* inspired by this post:
// http://madebyevan.com/shaders/fast-rounded-rectangle-shadows/
// Return the mask for the shadow of a box from lower to upper
fn rounded_box_shadow(lower: vec2<f32>, upper: vec2<f32>, in_point: vec2<f32>, sigma: f32, corner: f32) -> f32 {
// Center everything to make the math easier
var center: vec2<f32> = (lower + upper) * 0.5;
var half_size: vec2<f32> = (upper - lower) * 0.5;
var point = in_point - center;
// The signal is only non-zero in a limited range, so don't waste samples
var low: f32 = point.y - half_size.y;
var high: f32 = point.y + half_size.y;
var start: f32 = clamp(-3.0 * sigma, low, high);
var end: f32 = clamp(3.0 * sigma, low, high);
// Accumulate samples (we can get away with surprisingly few samples)
var step: f32 = (end - start) / 4.0;
var y: f32 = start + step * 0.5;
var value: f32 = 0.0;
for (var i = 0; i < 4; i++) {
value += rounded_box_shadow_x(point.x, point.y - y, sigma, corner, half_size) * gaussian(y, sigma) * step;
y += step;
}
return value;
}
// Return the blurred mask along the x dimension
fn rounded_box_shadow_x(x: f32, y: f32, sigma: f32, corner: f32, half_size: vec2<f32>) -> f32 {
var delta: f32 = min(half_size.y - corner - abs(y), 0.0);
var curved: f32 = half_size.x - corner + sqrt(max(0.0, corner * corner - delta * delta));
var integral: vec2<f32> = 0.5 + 0.5 * erf((x + vec2(-curved, curved)) * (sqrt(0.5) / sigma));
return integral.y - integral.x;
}
// This approximates the error function, needed for the gaussian integral
fn erf(x: vec2<f32>) -> vec2<f32> {
var s = sign(x);
var a = abs(x);
var denom = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a;
denom *= denom;
return s - s / (denom * denom);
}
// A standard gaussian function, used for weighting samples
fn gaussian(x: f32, sigma: f32) -> f32 {
return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(2.0 * PI) * sigma);
}
@@ -0,0 +1,97 @@
use crate::fonts::RasterizedGlyph;
use crate::rendering::atlas::AllocatedRegion;
use wgpu::{
BindGroup, BindGroupDescriptor, BindGroupLayout, Extent3d, Queue, Sampler,
TexelCopyBufferLayout, Texture, TextureDescriptor, TextureFormat, TextureUsages,
};
/// Helper struct that includes a [`Texture`] and its corresponding [`BindGroup`] for use in the
/// `GlyphCache`.
pub(super) struct TextureWithBindGroup {
texture: Texture,
/// The [`BindGroup`] associated with the `texture`. We compute this whenever we need to create
/// a new texture as a performance optimization to ensure we don't create it on every render.
bind_group: BindGroup,
}
impl TextureWithBindGroup {
pub(super) fn new(
size: usize,
device: &wgpu::Device,
bind_group_layout: &BindGroupLayout,
sampler: &Sampler,
) -> Self {
let texture = device.create_texture(&TextureDescriptor {
label: Some("Glyph atlas texture"),
size: Extent3d {
width: size as u32,
height: size as u32,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = device.create_bind_group(&BindGroupDescriptor {
layout: bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
label: None,
});
Self {
texture,
bind_group,
}
}
pub(super) fn insert_glyph_into_texture(
&mut self,
region: AllocatedRegion,
glyph: &RasterizedGlyph,
queue: &Queue,
) {
let bytes_per_row: u32 = 4 * (glyph.canvas.size.x() as u32);
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: 0,
origin: wgpu::Origin3d {
x: region.pixel_region.origin_x() as u32,
y: region.pixel_region.origin_y() as u32,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
glyph.canvas.pixels.as_slice(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: None,
},
Extent3d {
width: region.pixel_region.width() as u32,
height: region.pixel_region.height() as u32,
depth_or_array_layers: 1,
},
);
}
pub(super) fn bind_group(&self) -> &BindGroup {
&self.bind_group
}
}
+6
View File
@@ -0,0 +1,6 @@
#[cfg(winit)]
pub mod winit;
pub use galaxyui_core::windowing::*;
#[cfg(target_os = "linux")]
pub use winit::WindowingSystem;
+275
View File
@@ -0,0 +1,275 @@
use futures_util::future::LocalBoxFuture;
use std::mem::ManuallyDrop;
use crate::{
clipboard::ClipboardContent,
integration::TestDriver,
keymap,
platform::{self, TerminationMode},
AppContext, AssetProvider, WindowId,
};
use derivative::Derivative;
use super::window::{IntegrationTestWindowManager, WindowManager};
use crate::notification::RequestPermissionsOutcome;
use crate::platform::NotificationInfo;
#[cfg(target_os = "linux")]
use std::sync::OnceLock;
#[cfg(target_os = "linux")]
pub static WINDOWING_SYSTEM: OnceLock<WindowingSystem> = OnceLock::new();
pub type RequestPermissionsCallback =
Box<dyn FnOnce(RequestPermissionsOutcome, &mut AppContext) + Send + Sync>;
#[derive(Derivative)]
#[derivative(Debug)]
pub enum CustomEvent {
/// Open a window with the given window ID and options.
OpenWindow {
window_id: crate::WindowId,
window_options: platform::WindowOptions,
},
/// Run the wrapped task on the main thread.
RunTask(ManuallyDrop<async_task::Runnable>),
/// Exit the event loop, terminating the application.
Terminate(TerminationMode),
/// Close the specified window.
CloseWindow {
window_id: crate::WindowId,
termination_mode: TerminationMode,
},
/// A global hotkey was pressed. Global hotkeys are not yet supported on wasm.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
GlobalShortcutTriggered(keymap::Keystroke),
/// The active window changed.
///
/// We use this to trigger [`platform::AppCallbacks::on_active_window_changed`] instead of
/// winit's [`winit::event::WindowEvent::Focused`]. This is because winit's `Focused` event
/// actually fires twice when focus is transferred between 2 of Warp's own windows. But, we
/// only want to fire `on_active_window_changed` once for that focus change. So, we coalesce
/// multiple `Focused` events into a single `ActiveWindowChanged` event on the next tick of the
/// [`winit::event_loop::EventLoop`].
ActiveWindowChanged,
/// Update the UI App using the given closure.
UpdateUIApp(#[derivative(Debug = "ignore")] Box<dyn FnOnce(&mut AppContext) + Send + Sync>),
RequestUserAttention {
window_id: WindowId,
},
StopRequestingUserAttention {
window_id: WindowId,
},
#[allow(dead_code)]
Clipboard(ClipboardEvent),
SetCursorShape(platform::Cursor),
ActiveCursorPositionUpdated,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
AboutToSleep,
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
ResumedFromSleep,
/// The application is connected to the internet.
#[cfg_attr(any(target_os = "macos"), allow(dead_code))]
InternetConnected,
/// The application is disconnected from the internet.
#[cfg_attr(any(target_os = "macos"), allow(dead_code))]
InternetDisconnected,
/// The system theme (light/dark) changed.
/// TODO(CORE-2274): theming on Windows
#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
SystemThemeChanged,
/// Send a platform-native notification.
SendNotification {
window_id: WindowId,
notification_info: NotificationInfo,
},
/// Focus the native window that triggered a notification.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
FocusWindow {
window_id: WindowId,
},
RequestNotificationPermissions(#[derivative(Debug = "ignore")] RequestPermissionsCallback),
/// Fire a debounced drag-and-drop files event.
DragAndDropFilesDebounced {
window_id: winit::window::WindowId,
},
/// Input received from the soft keyboard on mobile WASM.
#[cfg(target_family = "wasm")]
SoftKeyboardInput(crate::platform::wasm::SoftKeyboardInput),
/// The visual viewport was resized (typically due to soft keyboard appearing/disappearing).
#[cfg(target_family = "wasm")]
VisualViewportResized {
width: f32,
height: f32,
},
/// Momentum scrolling animation frame.
MomentumScroll {
window_id: winit::window::WindowId,
},
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum ClipboardEvent {
Paste(ClipboardContent),
}
#[cfg(target_os = "linux")]
#[derive(Debug, PartialEq)]
pub enum WindowingSystem {
X11,
Wayland,
}
pub struct App {
callbacks: platform::app::AppCallbacks,
assets: Box<dyn AssetProvider>,
is_integration_test: bool,
window_class: Option<String>,
#[cfg(target_os = "linux")]
force_x11: bool,
}
impl App {
pub(crate) fn new(
callbacks: platform::app::AppCallbacks,
assets: Box<dyn AssetProvider>,
test_driver: Option<&TestDriver>,
) -> Self {
Self {
callbacks,
assets,
is_integration_test: test_driver.is_some(),
window_class: None,
#[cfg(target_os = "linux")]
force_x11: false,
}
}
// Dead code is allowed on wasm and Windows as the window class is only set for Linux
// platforms.
#[cfg_attr(any(target_family = "wasm", target_os = "windows"), allow(dead_code))]
pub(crate) fn set_window_class(&mut self, window_class: String) {
self.window_class = Some(window_class);
}
#[cfg(target_os = "linux")]
pub(crate) fn force_x11(&mut self, force_x11: bool) {
self.force_x11 = force_x11;
}
pub(crate) fn run(
self,
init_fn: impl FnOnce(&mut AppContext, LocalBoxFuture<'static, crate::App>) + 'static,
) {
let App {
callbacks,
assets,
is_integration_test,
window_class,
#[cfg(target_os = "linux")]
force_x11,
} = self;
let mut event_loop_builder = winit::event_loop::EventLoop::with_user_event();
#[cfg(target_os = "linux")]
if force_x11 {
winit::platform::x11::EventLoopBuilderExtX11::with_x11(&mut event_loop_builder);
}
let event_loop = event_loop_builder
.build()
.expect("should be able to create event loop");
// Initialize the wgpu instance with the event loop's display handle.
crate::rendering::wgpu::init_wgpu_instance(Box::new(event_loop.owned_display_handle()));
// Perform some platform-specific initialization.
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
super::linux::maybe_register_xlib_error_hook(&event_loop);
super::linux::ensure_cursor_theme();
} else if #[cfg(target_family = "wasm")] {
crate::platform::wasm::add_paste_listener(event_loop.create_proxy());
if callbacks.on_internet_reachability_changed.is_some() {
crate::platform::wasm::add_network_connection_listener(event_loop.create_proxy());
}
crate::platform::wasm::add_system_theme_listener(event_loop.create_proxy());
crate::platform::wasm::setup_visual_viewport_resize_listener(event_loop.create_proxy());
}
}
// Set the current thread as the main thread (the one that hosts the
// application event loop).
super::delegate::mark_current_thread_as_main();
let ui_app = Self::construct_ui_app(assets, is_integration_test, &event_loop);
let inner_event_loop = super::EventLoop::new(
ui_app,
callbacks,
init_fn,
window_class,
event_loop.create_proxy(),
);
// Prevent dropping of our internal event loop state structure during
// panic unwinds.
//
// We've seen crashes where a panic unwind leads to the dropping of the
// event loop, which ultimately causes a segfault in graphics driver
// code. Given the fact that we terminate the app via `exit(0)` and
// not by returning from the event loop, we don't ever need to drop the
// event loop, even during a panic unwind.
let mut inner_event_loop = std::mem::ManuallyDrop::new(inner_event_loop);
// Temporarily allow use of the deprecated run() method until winit
// 0.30 is here for good, at which point we'll migrate to the new
// trait-based APIs.
#[allow(deprecated)]
event_loop
.run(move |evt, window_target| {
inner_event_loop.handle_event(evt, window_target);
})
.expect("Unable to run winit event loop");
}
fn construct_ui_app(
assets: Box<dyn AssetProvider>,
is_integration_test: bool,
event_loop: &winit::event_loop::EventLoop<CustomEvent>,
) -> crate::App {
let platform_delegate: Box<dyn platform::Delegate> = if is_integration_test {
let delegate = super::delegate::IntegrationTestDelegate::new(event_loop.create_proxy())
.expect("should not fail to create platform delegate");
Box::new(delegate)
} else {
let mut delegate = super::delegate::AppDelegate::new(event_loop.create_proxy())
.expect("should not fail to create platform delegate");
delegate.use_platform_clipboard();
Box::new(delegate)
};
let display_handle = event_loop.owned_display_handle();
let window_manager: Box<dyn platform::WindowManager> = if is_integration_test {
Box::new(IntegrationTestWindowManager::new(
event_loop.create_proxy(),
display_handle,
))
} else {
Box::new(WindowManager::new(
event_loop.create_proxy(),
display_handle,
))
};
crate::App::new(
platform_delegate,
window_manager,
Box::new(super::fonts::FontDB::new()),
assets,
)
.expect("should not fail to construct application")
}
}
@@ -0,0 +1,688 @@
#![allow(unused)]
#[cfg(not(target_family = "wasm"))]
mod global_hotkey;
use std::mem::ManuallyDrop;
use std::{
cell::RefCell,
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
thread::{self, panicking},
};
use anyhow::Result;
use geometry::rect::RectF;
use itertools::Itertools;
use parking_lot::Mutex;
use serde::de::IntoDeserializer;
use winit::event_loop::{ActiveEventLoop, EventLoopProxy};
use crate::platform::MicrophoneAccessState;
use crate::platform::{
file_picker::{
FilePickerCallback, FilePickerError, SaveFilePickerCallback, SaveFilePickerConfiguration,
},
Cursor, RequestNotificationPermissionsCallback, SendNotificationErrorCallback,
};
use crate::windowing::winit::app::CustomEvent::UpdateUIApp;
use crate::windowing::WindowManager;
use crate::Effect::Event;
use crate::{
accessibility,
clipboard::{self, ClipboardContent, InMemoryClipboard},
geometry, keymap,
modals::{AlertDialog, ModalId},
notification, platform,
platform::file_picker::{FilePickerConfiguration, FileType},
windowing::{self, WindowCallbacks},
AppContext, ApplicationBundleInfo, Clipboard, DisplayId, DisplayIdx, WindowId,
};
use crate::{
notification::{NotificationSendError, RequestPermissionsOutcome},
platform::TerminationMode,
};
use super::{notifications, CustomEvent};
#[cfg(not(target_family = "wasm"))]
use self::global_hotkey::GlobalHotKeyHandler;
// No-op on WASM since the browser cannot provide this functionality.
#[cfg(target_family = "wasm")]
struct GlobalHotKeyHandler {}
#[cfg(target_family = "wasm")]
impl GlobalHotKeyHandler {
fn register(&self, _: keymap::Keystroke) {}
fn unregister(&self, _: &keymap::Keystroke) {}
}
/// 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();
/// Open a URL using the platform's default handler.
pub fn open_url_in_system(url: &str) {
#[cfg(target_family = "wasm")]
if let Some(window) = web_sys::window() {
// Try to open the URL in a new tab.
let _ = window.open_with_url_and_target(url, "_blank");
}
#[cfg(target_os = "linux")]
{
// Opening in WSL is complicated for a few reasons
// 1. By default, wsl does not have an awareness of browsers installed in windows.
// We either need to have wslu installed for wslview, or we need
// 2. We do not necessarily have things like xdg-utils installed, so relying on
// "native" opening of files is not necessarily going to work.
// We choose to do the following:
// 1. First attempt to open with `wslview`, since that is basically made to open stuff in wsl
// 2. Use `cmd.exe /c start {url}` to open in the user's default windows browser
// - If a user does not want this behavior, and wants all opening to go through
// WSL, they can set the env variable WARP_FORCE_WSL_BROWSER.
// 3. Fall back to default linux url opening behavior.
if platform::linux::is_wsl() {
match open::with_detached(url, "wslview") {
Ok(_) => return,
Err(e) => log::info!(
"Failed to open url with wslview {e:?}, falling back to another method"
),
};
// Attempt to open by
if !use_wsl_browser() {
let mut cmd = command::blocking::Command::new("cmd.exe");
cmd.args(["/c", "start", url]);
// Note: Ideally, we would be calling detached like open::that_detached does.
// However, it is probably fine.
match cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(_) => return,
Err(e) => log::info!(
"Failed to open url with cmd.exe {e:?}, falling back to another method"
),
}
}
}
if let Err(e) = open::that_detached(url) {
log::warn!("Unable to open url {e:?}");
}
}
#[cfg(windows)]
{
if let Err(e) = open::that_detached(url) {
log::warn!("Unable to open url {e:?}");
}
}
}
#[cfg(target_os = "linux")]
fn use_wsl_browser() -> bool {
static USE_WSL_BROWSER: OnceLock<bool> = OnceLock::new();
USE_WSL_BROWSER
.get_or_init(|| std::env::var("GALAXY_FORCE_WSL_BROWSER").is_ok())
.to_owned()
}
/// Marks the current thread as the application's main thread.
///
/// # Panics
///
/// 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 DispatchDelegate {
event_loop_proxy: Mutex<EventLoopProxy<super::CustomEvent>>,
}
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) {
// Surround the `task` in a `ManuallyDrop` so we can control when the task gets dropped.
// If the event loop is no longer running, sending the task over a channel will fail which
// causes the `task` to be dropped by _this_ thread. This in turns triggers a panic in
// `async-task` since the future is dropped by a different thread than what spawned it.
// In the case the event loop is no longer running, we will end up leaking the task until
// the process exits (which should happen imminently given the event loop has terminated).
self.event_loop_proxy
.lock()
.send_event(super::CustomEvent::RunTask(ManuallyDrop::new(task)));
}
}
pub struct AppDelegate {
/// A handle for enqueueing [`CustomEvent`]s into the main event loop.
pub(super) event_loop_proxy: EventLoopProxy<super::CustomEvent>,
clipboard: Box<dyn Clipboard>,
/// Responsible for registering the global hotkeys in the platform's desktop environment. Will
/// be `None` for platforms that can't support global hotkeys.
global_hotkey_handler: Option<GlobalHotKeyHandler>,
#[cfg(feature = "test-util")]
last_known_cursor: RefCell<Cursor>,
}
impl AppDelegate {
pub fn new(event_loop_proxy: EventLoopProxy<super::CustomEvent>) -> Result<Self> {
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
let global_hotkey_handler = None;
} else {
let global_hotkey_handler = match GlobalHotKeyHandler::new(event_loop_proxy.clone()) {
Ok(handler) => Some(handler),
Err(err) => {
log::error!("Error creating global hotkey handler: {err:?}");
None
}
};
}
}
Ok(Self {
event_loop_proxy,
clipboard: Box::<InMemoryClipboard>::default(),
global_hotkey_handler,
#[cfg(feature = "test-util")]
last_known_cursor: RefCell::new(Cursor::Arrow),
})
}
/// The way copy-paste is handled depends on the specific windowing system. As winit is
/// abstracting the windowing system, we need to ask it which one is running. We can do that by
/// matching against the display server raw handle.
pub fn use_platform_clipboard(&mut self) {
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
self.clipboard = Box::new(super::wasm::WebClipboard::new());
} else if #[cfg(target_os = "linux")] {
match super::linux::LinuxClipboard::new() {
Ok(clipboard) => self.clipboard = Box::new(clipboard),
Err(err) => {
log::error!("Error creating Linux clipboard: {err:?}");
}
}
} else if #[cfg(target_os = "windows")] {
match super::windows::WindowsClipboard::new() {
Ok(clipboard) => self.clipboard = Box::new(clipboard),
Err(err) => {
log::error!("Error creating Windows clipboard: {err:?}");
}
}
}
}
}
}
impl platform::Delegate for AppDelegate {
fn dispatch_delegate(&self) -> Arc<dyn platform::DispatchDelegate> {
Arc::new(DispatchDelegate {
event_loop_proxy: Mutex::new(self.event_loop_proxy.clone()),
})
}
fn request_user_attention(&self, window_id: WindowId) {
self.event_loop_proxy
.send_event(CustomEvent::RequestUserAttention { window_id });
}
fn clipboard(&mut self) -> &mut dyn crate::Clipboard {
self.clipboard.as_mut()
}
#[cfg(not(target_family = "wasm"))]
fn system_theme(&self) -> platform::SystemTheme {
#[cfg(target_os = "linux")]
match super::linux::get_system_theme() {
Ok(system_theme) => {
return system_theme;
}
Err(err) => {
log::info!("Unable to fetch Linux system color scheme: {err:#}");
}
}
#[cfg(target_os = "windows")]
match super::windows::get_system_theme() {
Ok(system_theme) => {
return system_theme;
}
Err(err) => {
log::warn!("Unable to fetch Windows system color scheme: {err:#?}");
}
}
platform::SystemTheme::Light
}
#[cfg(target_family = "wasm")]
fn system_theme(&self) -> platform::SystemTheme {
// To determine dark mode versus light mode, we check the CSS media query string "prefers-color-scheme". According
// to StackOverflow, this is the current consensus solution.
// See https://stackoverflow.com/questions/56393880/how-do-i-detect-dark-mode-using-javascript.
if let Ok(Some(media_query_list)) =
gloo::utils::window().match_media("(prefers-color-scheme: dark)")
{
if media_query_list.matches() {
return platform::SystemTheme::Dark;
}
}
platform::SystemTheme::Light
}
fn open_url(&self, url: &str) {
open_url_in_system(url);
}
fn open_file_path(&self, path: &Path) {
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
let _ = command::blocking::Command::new("xdg-open")
.arg(path)
.spawn();
} else if #[cfg(target_family = "wasm")] {
if let Some(window) = web_sys::window() {
if let Some(path) = path.to_str() {
// Try to open the path via a file:// URL.
let url = format!("file://{path}");
let _ = window.open_with_url(&url);
}
}
} else if #[cfg(windows)] {
if let Err(e) = open::that_detached(path) {
log::warn!("Unable to open path {e:?}");
}
}
}
}
fn open_file_picker(
&self,
callback: FilePickerCallback,
file_picker_config: FilePickerConfiguration,
) {
// TODO(wasm): Investigate implementing this by creating a <input> element
// and calling `click` on it.
#[cfg(not(target_family = "wasm"))]
{
// This callback is called either on the “File Picker” background thread or, if starting
// that thread fails, on this thread. Wrap this type in order to make ownership work.
let callback = Arc::new(takecell::TakeOwnCell::new(callback));
let callback_clone = callback.clone();
// Since native_dialog::FileDialog blocks while waiting for the user to select a file,
// put it in its own thread to avoid blocking the rest of the app.
let event_loop_proxy = self.event_loop_proxy.clone();
let thread_result = std::thread::Builder::new()
.name("File Picker".to_string())
.spawn(move || {
let file_type_names = file_picker_config
.file_types()
.iter()
.map(|file_type| file_type.display_name())
.join(", ");
let allowed_extensions = file_picker_config
.file_types()
.iter()
.map(|file_type| file_type.extensions())
.collect_vec()
.concat();
// native-dialog doesn't support file-or-directory or multi-directory pickers,
// so if folders are allowed, it can only show a directory picker.
let result = if file_picker_config.allows_folder() {
native_dialog::FileDialog::new()
.set_title("Choose directory...")
.show_open_single_dir()
.map(|opt| opt.into_iter().collect())
.map_err(|e| FilePickerError::DialogFailed(e.to_string()))
} else {
let mut file_dialog =
native_dialog::FileDialog::new().set_title("Choose file...");
if !allowed_extensions.is_empty() {
file_dialog = file_dialog.add_filter(
file_type_names.as_str(),
allowed_extensions.as_slice(),
);
}
if file_picker_config.allows_multi_select() {
file_dialog
.show_open_multiple_file()
.map_err(|e| FilePickerError::DialogFailed(e.to_string()))
} else {
file_dialog
.show_open_single_file()
.map(|opt| opt.into_iter().collect())
.map_err(|e| FilePickerError::DialogFailed(e.to_string()))
}
};
let result =
result.and_then(|file_result| {
file_result
.iter()
.map(|path_buf| {
path_buf.as_os_str().to_str().map(String::from).ok_or_else(
|| {
FilePickerError::DialogFailed(format!(
"Invalid path encoding: {:?}",
path_buf
))
},
)
})
.collect::<Result<Vec<_>, _>>()
});
event_loop_proxy.send_event(CustomEvent::UpdateUIApp(Box::new(move |app| {
if let Some(callback) = callback_clone.take() {
callback(result, app);
}
})));
});
if let Err(e) = thread_result {
self.event_loop_proxy
.send_event(CustomEvent::UpdateUIApp(Box::new(move |app| {
if let Some(callback) = callback.take() {
callback(Err(FilePickerError::ThreadSpawnFailed(Arc::new(e))), app);
}
})));
}
}
}
fn open_save_file_picker(
&self,
callback: SaveFilePickerCallback,
config: SaveFilePickerConfiguration,
) {
#[cfg(not(target_family = "wasm"))]
{
let event_loop_proxy = self.event_loop_proxy.clone();
std::thread::Builder::new()
.name("Save File Picker".to_string())
.spawn(move || {
let mut file_dialog =
native_dialog::FileDialog::new().set_title("Save file as...");
if let Some(default_filename) = config.default_filename.as_ref() {
file_dialog = file_dialog.set_filename(default_filename);
}
if let Some(default_directory) = config.default_directory.as_ref() {
file_dialog = file_dialog.set_location(default_directory);
}
let file_result = file_dialog.show_save_single_file().unwrap_or_else(|err| {
log::error!("unable to show save file dialog: {err:?}");
None
});
let path = file_result
.and_then(|path_buf| path_buf.as_os_str().to_str().map(String::from));
event_loop_proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|app| {
callback(path, app);
})));
});
}
}
fn application_bundle_info(
&self,
bundle_identifier: &str,
) -> Option<ApplicationBundleInfo<'_>> {
None
}
fn request_desktop_notification_permissions(
&self,
on_completion: RequestNotificationPermissionsCallback,
) {
notifications::request_desktop_notification_permissions(
on_completion,
&self.event_loop_proxy,
);
}
#[cfg(feature = "test-util")]
fn get_cursor_shape(&self) -> Cursor {
*self.last_known_cursor.borrow()
}
fn send_desktop_notification(
&self,
notification_content: notification::UserNotification,
window_id: WindowId,
on_error: SendNotificationErrorCallback,
) {
notifications::send_desktop_notification(
notification_content,
window_id,
on_error,
&self.event_loop_proxy,
)
}
fn set_cursor_shape(&self, cursor: Cursor) {
#[cfg(test)]
{
*self.last_known_cursor.borrow_mut() = cursor;
}
self.event_loop_proxy
.send_event(CustomEvent::SetCursorShape(cursor));
}
fn close_ime_async(&self, _window_id: WindowId) {
// TODO(wasm): implement this.
}
fn is_ime_open(&self) -> bool {
// TODO(wasm): implement this.
false
}
fn open_character_palette(&self) {
// TODO(wasm): Implement this.
}
fn set_accessibility_contents(&self, content: accessibility::AccessibilityContent) {
// TODO(wasm): Implement this.
}
fn register_global_shortcut(&self, shortcut: keymap::Keystroke) {
if let Some(handler) = &self.global_hotkey_handler {
handler.register(shortcut);
}
}
fn unregister_global_shortcut(&self, shortcut: &keymap::Keystroke) {
if let Some(handler) = &self.global_hotkey_handler {
handler.unregister(shortcut);
}
}
fn terminate_app(&self, terminaton_mode: TerminationMode) {
self.event_loop_proxy
.send_event(CustomEvent::Terminate(terminaton_mode));
}
fn is_screen_reader_enabled(&self) -> Option<bool> {
// TODO(wasm): Implement this.
None
}
fn microphone_access_state(&self) -> MicrophoneAccessState {
// Note that for voice input, we can actually detect microphone access state
// in the course of trying to start voice input, but we don't have a way to do
// it at arbitrary times, so we just return NotDetermined here.
MicrophoneAccessState::NotDetermined
}
fn open_file_path_in_explorer(&self, path: &Path) {
if path.is_dir() {
self.open_file_path(path);
} else if let Some(parent_path) = path.parent() {
if parent_path.is_dir() {
self.open_file_path(parent_path);
} else {
log::info!("Parent directory is not a valid directory, not opening file")
}
} else {
log::info!("Neither file nor parent was a valid directory, not opening file");
}
}
fn show_native_platform_modal(&self, _id: ModalId, _modal: AlertDialog) {
// TODO
}
}
pub struct IntegrationTestDelegate {
app_delegate: AppDelegate,
clipboard: InMemoryClipboard,
}
impl IntegrationTestDelegate {
pub fn new(event_loop_proxy: EventLoopProxy<super::CustomEvent>) -> Result<Self> {
Ok(IntegrationTestDelegate {
app_delegate: AppDelegate::new(event_loop_proxy)?,
clipboard: InMemoryClipboard::default(),
})
}
}
impl platform::Delegate for IntegrationTestDelegate {
fn dispatch_delegate(&self) -> Arc<dyn platform::DispatchDelegate> {
self.app_delegate.dispatch_delegate()
}
fn request_user_attention(&self, _window_id: WindowId) {
// no-op
}
fn clipboard(&mut self) -> &mut dyn crate::Clipboard {
&mut self.clipboard
}
fn system_theme(&self) -> platform::SystemTheme {
self.app_delegate.system_theme()
}
fn open_url(&self, _: &str) {
// no-op
}
fn open_file_path(&self, _: &Path) {
// no-op
}
fn open_file_picker(
&self,
_callback: FilePickerCallback,
_file_picker_config: FilePickerConfiguration,
) {
// no-op
}
fn open_save_file_picker(
&self,
_callback: SaveFilePickerCallback,
_config: SaveFilePickerConfiguration,
) {
// no-op
}
fn application_bundle_info(&self, _: &str) -> Option<ApplicationBundleInfo<'_>> {
None
}
fn microphone_access_state(&self) -> MicrophoneAccessState {
MicrophoneAccessState::NotDetermined
}
fn request_desktop_notification_permissions(
&self,
_on_completion: RequestNotificationPermissionsCallback,
) {
// no-op
}
fn send_desktop_notification(
&self,
_notification_content: notification::UserNotification,
_window_id: WindowId,
_on_error: SendNotificationErrorCallback,
) {
// no-op
}
#[cfg(feature = "test-util")]
fn get_cursor_shape(&self) -> platform::Cursor {
self.app_delegate.get_cursor_shape()
}
fn set_cursor_shape(&self, cursor: platform::Cursor) {
self.app_delegate.set_cursor_shape(cursor)
}
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, _: accessibility::AccessibilityContent) {
// no-op
}
fn register_global_shortcut(&self, shortcut: keymap::Keystroke) {
self.app_delegate.register_global_shortcut(shortcut)
}
fn unregister_global_shortcut(&self, shortcut: &keymap::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 open_file_path_in_explorer(&self, path: &Path) {
// no-op
}
fn show_native_platform_modal(&self, _id: ModalId, _modal: AlertDialog) {
// no-op
}
}
@@ -0,0 +1,193 @@
use std::{collections::HashMap, rc::Rc, str::FromStr, sync::Arc, thread};
use crate::keymap;
use crate::windowing::winit::app::CustomEvent;
use parking_lot::Mutex;
use winit::event_loop::EventLoopProxy;
use global_hotkey::{
hotkey::{Code, HotKey, Modifiers},
GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState,
};
/// Responsible for registering system-wide (global) hotkeys with the platform.
pub struct GlobalHotKeyHandler {
platform_manager: std::cell::OnceCell<GlobalHotKeyManager>,
/// Maps the [`global_hotkey::hotkey::HotKey::id`], an opaque, hash-based integer, to our
/// [`keymap::Keystroke`].
hotkey_map: Arc<Mutex<HashMap<u32, keymap::Keystroke>>>,
event_loop_proxy: EventLoopProxy<CustomEvent>,
}
impl GlobalHotKeyHandler {
pub fn new(
event_loop_proxy: EventLoopProxy<CustomEvent>,
) -> Result<Self, global_hotkey::Error> {
Ok(Self {
platform_manager: Default::default(),
hotkey_map: Default::default(),
event_loop_proxy,
})
}
pub fn register(&self, shortcut: keymap::Keystroke) {
let hotkey = match hotkey_for_keystroke(&shortcut) {
Ok(hotkey) => hotkey,
Err(e) => {
log::error!("invalid global hotkey: {e:?}");
return;
}
};
self.platform_manager().register(hotkey);
self.hotkey_map.lock().insert(hotkey.id(), shortcut);
}
pub fn unregister(&self, shortcut: &keymap::Keystroke) {
let hotkey = match hotkey_for_keystroke(shortcut) {
Ok(hotkey) => hotkey,
Err(e) => {
log::error!("invalid global hotkey: {e:?}");
return;
}
};
self.platform_manager().unregister(hotkey);
self.hotkey_map.lock().remove(&hotkey.id());
}
/// Returns a reference to a lazily-instantiated [`GlobalHotKeyManager`].
///
/// We do this lazily because the [`GlobalHotKeyManager`] can interfere
/// with other libraries that use Xlib, leading to crashes. We don't want
/// to run the risk of this happening for users who haven't set any global
/// hotkeys.
fn platform_manager(&self) -> &GlobalHotKeyManager {
self.platform_manager.get_or_init(|| {
let platform_manager =
GlobalHotKeyManager::new().expect("x11 implementation never actually fails");
let thread_hotkey_map = self.hotkey_map.clone();
// When global hotkeys are triggered, events get published to a crossbeam channel.
// Since crossbeam channels are not async, we don't want to receive this on our
// background executor's thread pool, as that would block a thread. Therefore, we spawn
// a dedicated thread for receiving these events.
let event_loop_proxy = self.event_loop_proxy.clone();
thread::spawn(move || {
while let Ok(event) = GlobalHotKeyEvent::receiver().recv() {
// Trigger when the hotkey is released, _not_ pressed. This is due to an X11
// quirk where focus is transferred out of Warp windows after a global hotkey
// is pressed. This breaks our quake mode logic. However, focus is restored
// when the hotkey is released.
if event.state == HotKeyState::Released {
// Lookup the hash-based hotkey ID to the actual keystroke from our
// map.
if let Some(keystroke) = thread_hotkey_map.lock().get(&event.id) {
event_loop_proxy.send_event(CustomEvent::GlobalShortcutTriggered(
keystroke.clone(),
));
}
}
}
});
platform_manager
})
}
}
fn hotkey_for_keystroke(
keystroke: &keymap::Keystroke,
) -> std::result::Result<HotKey, anyhow::Error> {
let mut mods = Modifiers::empty();
if keystroke.alt {
mods |= Modifiers::ALT;
}
if keystroke.cmd {
mods |= Modifiers::SUPER;
}
if keystroke.shift {
mods |= Modifiers::SHIFT;
}
if keystroke.ctrl {
mods |= Modifiers::CONTROL;
}
if keystroke.meta {
mods |= Modifiers::META;
}
let key = if keystroke.key.len() == 1 {
let c = keystroke
.key
.chars()
.next()
.expect("validated length already");
match c {
'`' | '~' => Code::Backquote,
'-' | '_' => Code::Minus,
'=' | '+' => Code::Equal,
'0'..='9' => Code::from_str(&format!("Digit{c}"))?,
'\t' => Code::Tab,
'!' => Code::Digit1,
'@' => Code::Digit2,
'#' => Code::Digit3,
'$' => Code::Digit4,
'%' => Code::Digit5,
'^' => Code::Digit6,
'&' => Code::Digit7,
'*' => Code::Digit8,
'(' => Code::Digit9,
')' => Code::Digit0,
'a'..='z' | 'A'..='Z' => Code::from_str(&format!("Key{}", c.to_ascii_uppercase()))?,
'[' | '{' => Code::BracketLeft,
']' | '}' => Code::BracketRight,
'\\' | '|' => Code::Backslash,
';' => Code::Semicolon,
'\'' | '"' => Code::Quote,
',' | '<' => Code::Comma,
'.' | '>' => Code::Period,
'/' | '?' => Code::Slash,
'ろ' => Code::IntlRo,
'¥' => Code::IntlYen,
' ' => Code::Space,
_ => anyhow::bail!("Invalid global hotkey: {c}"),
}
} else {
// Must map each of [`keymap::VALID_SPECIAL_KEYS`] to [`global_hotkey::hotkey::Code`].
match keystroke.key.as_str() {
"backspace" => Code::Backspace,
"tab" => Code::Tab,
"enter" => Code::Enter,
"up" => Code::ArrowUp,
"down" => Code::ArrowDown,
"left" => Code::ArrowLeft,
"right" => Code::ArrowRight,
"home" => Code::Home,
"end" => Code::End,
"pageup" => Code::PageUp,
"pagedown" => Code::PageDown,
"insert" => Code::Insert,
"delete" => Code::Delete,
"escape" => Code::Escape,
"numpadenter" => Code::NumpadEnter,
"f1" => Code::F1,
"f2" => Code::F2,
"f3" => Code::F3,
"f4" => Code::F4,
"f5" => Code::F5,
"f6" => Code::F6,
"f7" => Code::F7,
"f8" => Code::F8,
"f9" => Code::F9,
"f10" => Code::F10,
"f11" => Code::F11,
"f12" => Code::F12,
"f13" => Code::F13,
"f14" => Code::F14,
"f15" => Code::F15,
"f16" => Code::F16,
"f17" => Code::F17,
"f18" => Code::F18,
"f19" => Code::F19,
"f20" => Code::F20,
s => anyhow::bail!("Invalid global hotkey: {s}"),
}
};
Ok(HotKey::new(Some(mods), key))
}
@@ -0,0 +1,89 @@
use super::*;
use std::path::PathBuf;
use winit::window::WindowId as WinitWindowId;
#[test]
fn test_drag_drop_debouncing_single_file() {
// Create a mock event loop structure
let window_id = WinitWindowId::from(1u64);
let mut state = State::default();
state
.windows
.insert(window_id, WindowState::new(crate::WindowId::new()));
// Simulate a single file drop
let path_buf = PathBuf::from("/path/to/file.txt");
// Process the event - this would normally be done by the event loop
if let Some(window_state) = state.windows.get_mut(&window_id) {
if let Some(path) = path_buf.as_os_str().to_str() {
window_state.pending_drag_drop_files.push(path.to_string());
assert_eq!(window_state.pending_drag_drop_files.len(), 1);
assert_eq!(window_state.pending_drag_drop_files[0], "/path/to/file.txt");
// Verify timer flag is set correctly
window_state.has_pending_drag_drop_timer = true;
assert!(window_state.has_pending_drag_drop_timer);
}
}
}
#[test]
fn test_drag_drop_debouncing_multiple_files() {
let window_id = WinitWindowId::from(1u64);
let mut state = State::default();
state
.windows
.insert(window_id, WindowState::new(crate::WindowId::new()));
// Simulate multiple file drops
let files = vec![
"/path/to/file w spaces.txt",
"/path/to/file2.txt",
"/path/to/file3.txt",
];
if let Some(window_state) = state.windows.get_mut(&window_id) {
for file_path in files {
window_state
.pending_drag_drop_files
.push(file_path.to_string());
}
assert_eq!(window_state.pending_drag_drop_files.len(), 3);
assert_eq!(
window_state.pending_drag_drop_files[0],
"/path/to/file w spaces.txt"
);
assert_eq!(
window_state.pending_drag_drop_files[1],
"/path/to/file2.txt"
);
assert_eq!(
window_state.pending_drag_drop_files[2],
"/path/to/file3.txt"
);
}
}
#[test]
fn test_empty_drag_drop_handling() {
let window_id = WinitWindowId::from(1u64);
let mut state = State::default();
state
.windows
.insert(window_id, WindowState::new(crate::WindowId::new()));
if let Some(window_state) = state.windows.get_mut(&window_id) {
// Verify that empty file list is handled correctly
assert!(window_state.pending_drag_drop_files.is_empty());
// Simulate debounced event handling with empty list
window_state.has_pending_drag_drop_timer = false;
if window_state.pending_drag_drop_files.is_empty() {
// Should return early without creating an event
assert!(window_state.pending_drag_drop_files.is_empty());
}
}
}
@@ -0,0 +1,258 @@
use std::borrow::Cow;
use std::collections::HashMap;
use lazy_static::lazy_static;
use winit::event::ElementState;
#[cfg(windows)]
use winit::keyboard::NativeKey;
use winit::keyboard::{Key, ModifiersState, NamedKey};
#[cfg(not(target_family = "wasm"))]
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
use crate::platform::KEYS_TO_IGNORE;
use crate::{event::KeyEventDetails, keymap::Keystroke};
use super::WindowState;
lazy_static! {
/// Mapping between a printable ASCII character and its corresponding control code had `ctrl`
/// been pressed. For example: `ctrl-c` corresponds to the `^C` control code, which has an ASCII
/// value of 03. See <https://www.geeksforgeeks.org/control-characters/> for more details.
static ref CONTROL_CHARACTER_MAP: HashMap<&'static str, &'static str> = HashMap::from_iter([
("@", "\x00"),
("a", "\x01"),
("b", "\x02"),
("c", "\x03"),
("d", "\x04"),
("e", "\x05"),
("f", "\x06"),
("g", "\x07"),
("h", "\x08"),
("i", "\x09"),
("j", "\x0A"),
("k", "\x0B"),
("l", "\x0C"),
("m", "\x0D"),
("n", "\x0E"),
("o", "\x0F"),
("p", "\x10"),
("q", "\x11"),
("r", "\x12"),
("s", "\x13"),
("t", "\x14"),
("u", "\x15"),
("v", "\x16"),
("w", "\x17"),
("x", "\x18"),
("y", "\x19"),
("z", "\x1A"),
("[", "\x1B"),
("\\", "\x1C"),
("]", "\x1D"),
("^", "\x1E"),
("_", "\x1F"),
]);
}
/// Converts a KeyboardInput event to a UI framework event, returning None
/// if no UI framework event should be emitted.
pub fn convert_keyboard_input_event(
input: winit::event::KeyEvent,
window_state: &WindowState,
is_synthetic: bool,
) -> Option<crate::Event> {
if input.state != ElementState::Pressed {
return None;
}
// Ignore any synthetic keypresses that winit generated for keys that were
// already pressed when a window gained focus. Three examples of how these
// cause problems:
// 1. An alt-tab to a window can end up inserting a tab into the input if
// alt is released before tab.
// 2. Using a keyboard shortcut to open a new window can open many new
// windows, as the new window will receive a synthetic event for the
// shortcut that opened it, opening _another_ new window, and so on.
// 3. The ctrl-d shortcut for sending an EOF to the shell can end up
// being sent to additional sessions if there was ony one session in
// the window, as it will close the window and then be synthetically
// generated for the next window in the stack.
if is_synthetic {
return None;
}
let chars = text_with_modifiers(&input, window_state.modifiers)
.unwrap_or_default()
.to_owned();
let key_without_modifiers = get_key_without_modifiers(&input);
let shift = window_state.modifiers.shift_key();
let logical_key = match &input.logical_key {
// When keystrokes with ctrl-alt are pressed on Windows, `input.logical_key` is
// Unidentified.
#[cfg(windows)]
Key::Unidentified(NativeKey::Windows(_))
if window_state
.modifiers
.contains(ModifiersState::CONTROL | ModifiersState::ALT) =>
{
input.key_without_modifiers()
}
_ => input.logical_key,
};
let input_key = get_input_key(&logical_key, shift);
let key = convert_key(input_key)?.to_string();
let keystroke = Keystroke {
ctrl: window_state.modifiers.control_key(),
alt: window_state.modifiers.alt_key(),
shift,
cmd: window_state.modifiers.super_key(),
meta: false,
key,
};
// Ignore any keystrokes that we're purposefully not handling. (I.e. cmdorctrl-v needs to fall back
// to the browser implementation on the web.)
if KEYS_TO_IGNORE.contains(&keystroke) {
return None;
}
Some(crate::event::Event::KeyDown {
keystroke,
chars,
details: KeyEventDetails {
left_alt: window_state.left_alt_pressed,
right_alt: window_state.right_alt_pressed,
key_without_modifiers,
},
is_composing: false,
})
}
#[cfg(not(target_family = "wasm"))]
/// Returns the base key without any modifiers applied, or `None` if it cannot be determined.
fn get_key_without_modifiers(input: &winit::event::KeyEvent) -> Option<String> {
let unmodified = input.key_without_modifiers();
let unmodified_input = get_input_key(&unmodified, false);
convert_key(unmodified_input).map(|k| k.to_string())
}
#[cfg(target_family = "wasm")]
fn get_key_without_modifiers(_input: &winit::event::KeyEvent) -> Option<String> {
None
}
#[cfg(not(target_family = "wasm"))]
/// Returns the text of the [`winit::event::KeyEvent`] with the characters modified by `ctrl`.
/// For example, `Ctrl+a` produces `Some("\x01")`.
fn text_with_modifiers(
key_event: &winit::event::KeyEvent,
_modifier_state: ModifiersState,
) -> Option<&str> {
key_event.text_with_all_modifiers()
}
#[cfg(target_family = "wasm")]
fn text_with_modifiers(
key_event: &winit::event::KeyEvent,
modifier_state: ModifiersState,
) -> Option<&str> {
// Provide the bare-minimum amount of support for mapping modifiers to their corresponding
// ASCII character. This is not actually fully functional because keys like `@` require the
// addition of the `SHIFT` key, which doesn't yet work here.
// TODO(wasm): Extend this to support all of the function/shift/arrow keys.
match (modifier_state, &key_event.logical_key) {
(ModifiersState::CONTROL, Key::Character(character))
if CONTROL_CHARACTER_MAP.contains_key(character.as_str()) =>
{
CONTROL_CHARACTER_MAP.get(character.as_str()).copied()
}
(_, key) => key.to_text(),
}
}
fn get_input_key(logical_key: &Key, is_shift: bool) -> Key {
use winit::keyboard::Key::Character;
match (logical_key, is_shift) {
// If the key is a character AND shift is pressed, we force the key to uppercase.
// If the key is a character AND shift is NOT pressed, we force the key to lowercase.
// This is to align with existing behavior where we expect bindings with shift
// to have uppercase characters, and bindings without shift to have lowercase characters.
// See galaxyui::keymap::Keystroke::parse and galaxy::util::bindings::cmd_or_ctrl_shift.
(Character(character), true) => Character(character.to_uppercase().into()),
(Character(character), false) => Character(character.to_lowercase().into()),
(non_char_key, _) => non_char_key.clone(),
}
}
/// Converts a winit [`winit::keyboard::Key`] to the corresponding string version
/// expected by the UI framework.
fn convert_key(key: Key) -> Option<Cow<'static, str>> {
use winit::keyboard::Key::*;
let value = match key {
Character(char) => return Some(char.to_string().into()),
Named(NamedKey::Enter) => "enter",
Named(NamedKey::Tab) => "tab",
Named(NamedKey::Space) => " ",
Named(NamedKey::ArrowDown) => "down",
Named(NamedKey::ArrowLeft) => "left",
Named(NamedKey::ArrowRight) => "right",
Named(NamedKey::ArrowUp) => "up",
Named(NamedKey::End) => "end",
Named(NamedKey::Home) => "home",
Named(NamedKey::PageDown) => "pagedown",
Named(NamedKey::PageUp) => "pageup",
Named(NamedKey::Backspace) => "backspace",
Named(NamedKey::Delete) => "delete",
Named(NamedKey::Insert) => "insert",
Named(NamedKey::Escape) => "escape",
Named(NamedKey::F1) => "f1",
Named(NamedKey::F2) => "f2",
Named(NamedKey::F3) => "f3",
Named(NamedKey::F4) => "f4",
Named(NamedKey::F5) => "f5",
Named(NamedKey::F6) => "f6",
Named(NamedKey::F7) => "f7",
Named(NamedKey::F8) => "f8",
Named(NamedKey::F9) => "f9",
Named(NamedKey::F10) => "f10",
Named(NamedKey::F11) => "f11",
Named(NamedKey::F12) => "f12",
Named(NamedKey::F13) => "f13",
Named(NamedKey::F14) => "f14",
Named(NamedKey::F15) => "f15",
Named(NamedKey::F16) => "f16",
Named(NamedKey::F17) => "f17",
Named(NamedKey::F18) => "f18",
Named(NamedKey::F19) => "f19",
Named(NamedKey::F20) => "f20",
Named(NamedKey::F21) => "f21",
Named(NamedKey::F22) => "f22",
Named(NamedKey::F23) => "f23",
Named(NamedKey::F24) => "f24",
Named(NamedKey::F25) => "f25",
Named(NamedKey::F26) => "f26",
Named(NamedKey::F27) => "f27",
Named(NamedKey::F28) => "f28",
Named(NamedKey::F29) => "f29",
Named(NamedKey::F30) => "f30",
Named(NamedKey::F31) => "f31",
Named(NamedKey::F32) => "f32",
Named(NamedKey::F33) => "f33",
Named(NamedKey::F34) => "f34",
Named(NamedKey::F35) => "f35",
_ => return None,
};
Some(Cow::Borrowed(value))
}
#[cfg(test)]
#[path = "key_events_tests.rs"]
mod tests;
@@ -0,0 +1,50 @@
use super::get_input_key;
use winit::keyboard::{Key::Character, SmolStr};
#[test]
fn test_get_input_key() {
// Tests all visible ASCII characters
// TODO: it would be nice to test the following:
// - non-Character keys (ex: named keys, dead keys)
// - non-ascii characters to ensure shift behavior is appropriate
for ascii_code in 32u8..127u8 {
let input = ascii_code as char;
let key = Character(SmolStr::from(input.to_string()));
for shift in [false, true] {
match get_input_key(&key, shift) {
Character(new_value) => {
let new_char = new_value
.chars()
.next()
.expect("string should be non-empty");
let expected = match (input, shift) {
('A'..='Z', false) => input
.to_lowercase()
.next()
.expect("string should be non-empty"),
// Case 2: a lower case letter when shift is true
// Should turn into upper case version
('a'..='z', true) => input
.to_uppercase()
.next()
.expect("string should be non-empty"),
// Case 3: a character that should be unchanged by caps lock
// - An upper-case letter when shift is true
// - A lower-case letter when shift is false,
// - A non-alpha character
_ => input,
};
assert_eq!(
expected, new_char,
"Expected '{input}' -> '{expected}' when shift={shift}, but got '{new_char}'"
)
}
unexpected => {
panic!("Key '{key:?}' somehow became non-character {unexpected:?}")
}
}
}
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More