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
+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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
// Neither macOS nor wasm make use of the "load font from path" functionality,
// and so there's a lot of unused code in here. Instead of marking each of the
// relevant functions with allow(dead_code), we'll do it at the module level
// instead for simplicity.
#![cfg_attr(
any(target_os = "macos", target_os = "windows", target_family = "wasm"),
allow(dead_code)
)]
use owned_ttf_parser::{AsFaceRef, Face, FaceParsingError, OwnedFace};
use std::fs::File;
use std::path::PathBuf;
/// A handle that wraps around a font face.
pub struct FontHandle {
data: FontData,
}
/// Source data for a font to be loaded within the winit font system.
pub enum FontData {
/// The font is to be loaded via bytes. This should be used sparingly since it requires loading the font into
/// memory.
Bytes(OwnedFace),
/// The font identified at the given `path` and `index` will be loaded.
/// NOTE the font will never be loaded into memory. Instead, data from the font will be read via a memory-mapped
/// file.
Path {
path: PathBuf,
index: u32,
is_monospace: bool,
},
}
impl FontData {
/// Returns an [`Error`] if the [`FontData`] does not map to a valid font.
///
/// A font is considered valid iff:
/// * The file referenced by [`FontData::Path`] exists and can be read.
/// * The data can be parsed into a valid [`ttf_parser::Face`].
/// * The font face contains a glyph for the 'm' character.
fn validate(&self) -> Result<(), Error> {
match self {
FontData::Bytes(_) => Ok(()),
FontData::Path { path, index, .. } => {
let file = File::open(path).map_err(|e| Error::Load {
path: path.clone(),
io_error: e,
})?;
let mmap = unsafe {
memmap2::Mmap::map(&file).map_err(|e| Error::Load {
path: path.clone(),
io_error: e,
})?
};
let face = Face::parse(&mmap, *index).map_err(|e| Error::Parse {
path: path.clone(),
parse_error: e,
})?;
if face.as_face_ref().glyph_index('m').is_none() {
Err(Error::Validate { path: path.clone() })
} else {
Ok(())
}
}
}
}
}
impl FontHandle {
pub fn new(path: impl Into<PathBuf>, index: u32, is_monospace: bool) -> Self {
Self {
data: FontData::Path {
path: path.into(),
index,
is_monospace,
},
}
}
pub fn is_monospace(&self) -> bool {
match &self.data {
FontData::Path { is_monospace, .. } => *is_monospace,
FontData::Bytes(face) => face.as_face_ref().is_monospaced(),
}
}
/// Validates the the [`FontHandle`] is a parseable font.
pub fn validate_font_data(&self) -> Result<(), Error> {
self.data.validate()
}
pub(super) fn into_data(self) -> FontData {
self.data
}
#[allow(dead_code)]
pub(super) fn data(&self) -> &FontData {
&self.data
}
}
impl From<OwnedFace> for FontHandle {
fn from(value: OwnedFace) -> Self {
Self {
data: FontData::Bytes(value),
}
}
}
/// Errors associated with loading fonts
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Failed to load font data due to an underlying std::io::Error
#[error("Error loading font data for font {path}")]
Load {
path: PathBuf,
io_error: std::io::Error,
},
/// Failed to parse the underlying data into a valid font
#[error("Error parsing font data for font {path}")]
Parse {
path: PathBuf,
parse_error: FaceParsingError,
},
/// A font was properly loaded, but did not have a codepoint
/// for the letter m, indicating it would not work within Warp.
#[error("Font {path} does not have a valid codepoint for the letter m")]
Validate { path: PathBuf },
}
@@ -0,0 +1,369 @@
//! Loads fonts on linux.
//!
//! Handles discovering and loading fonts on linux systems.
//! Leverages the fontconfig crate to detect all fonts
//! available on the user's device, creating handles for the fonts.
//! Handles can be converted to owned_ttf_parser::OwnedFace objects
//! by loading the fonts into memory.
use std::ffi::c_int;
use std::{collections::HashMap, ffi::CString};
use super::{
font_handle::{Error as FontDataError, FontHandle},
FontFamily, ValidateFontSupportsEn,
};
use crate::fonts::{FontInfo, Properties, Style, Weight};
use fontconfig::{
list_fonts, sort_fonts, FontSet, Fontconfig, ObjectSet, Pattern, FC_FAMILY, FC_FILE,
FC_FONTFORMAT, FC_FULLNAME, FC_INDEX, FC_LANG, FC_MONO, FC_SLANT, FC_SLANT_ITALIC,
FC_SLANT_ROMAN, FC_SPACING, FC_WEIGHT, FC_WEIGHT_BLACK, FC_WEIGHT_BOLD, FC_WEIGHT_EXTRABOLD,
FC_WEIGHT_EXTRALIGHT, FC_WEIGHT_LIGHT, FC_WEIGHT_MEDIUM, FC_WEIGHT_NORMAL, FC_WEIGHT_SEMIBOLD,
FC_WEIGHT_THIN,
};
use itertools::Itertools;
/// Manages font detection and handle generation.
///
/// Contains our font loading object, wrapping around fontconfig::FontConfig
/// to query the available fonts on the system and return handles grouped into
/// families
pub struct FontconfigLoader {
fc: Fontconfig,
}
impl FontconfigLoader {
/// Creates a new FontLoader instance.
///
/// # Errors
///
/// Will return an Error::Init if the underlying FFI wrapper
/// for Fontconfig fails to initialize
pub fn new() -> Result<Self, Error> {
if let Some(fc) = Fontconfig::new() {
Ok(Self { fc })
} else {
Err(Error::Init)
}
}
/// Gets a handle for a single font family.
///
/// Looks up all fonts in the font family specified by `family_name`.
/// Returns a FamilyHandle for those fonts
///
/// # Errors
/// If there are zero valid fonts within the family, this will error with
/// Error::FamilyHasNoFonts
///
/// Additionally, passing a malformed CString name (ex: a string w/ a null terminator)
/// can trigger an Error::InvalidFontName.
pub(super) fn get_family(&self, family_name: &str) -> Result<FamilyHandle, Error> {
let fonts = self.query_fonts(Some(family_name))?;
let mut family = FamilyHandle::new(family_name);
let mut errors = Vec::<Error>::new();
for pattern in fonts.iter() {
match Self::parse_font(pattern, ValidateFontSupportsEn::Yes) {
Ok(font) => family.add_font(font),
Err(err) => errors.push(err),
}
}
if !family.fonts.is_empty() {
Ok(family)
} else {
Err(Error::FamilyHasNoFonts(family_name.to_string(), errors))
}
}
// Gets handles for all font families present on the device.
//
// Searches for all available fonts on the device, and returns
// font families for all valid results. A font is considered valid if:
//
// * It has a valid family_name, filename, and face_index
// * It supports the language 'en'.
// * It has a TTF or CFF format
//
// Any invalid fonts are skipped over, with logging explaining why it was skipped
pub(super) fn get_all_families(&self) -> Result<Vec<FamilyHandle>, Error> {
let fonts = self.query_fonts(None)?;
let mut family_map = HashMap::new();
for pattern in fonts.iter() {
let font_name = pattern.name().unwrap_or("unknown");
let Some(family_name) = pattern.get_string(FC_FAMILY).map(|name| name.to_string())
else {
log::warn!("could not parse font_family for font {font_name}",);
continue;
};
let font_handle = match Self::parse_font(pattern, ValidateFontSupportsEn::Yes) {
Ok(handle) => handle,
Err(_) => continue,
};
family_map
.entry(family_name.to_string())
.or_insert_with(|| FamilyHandle::new(&family_name))
.add_font(font_handle);
}
let mut results = family_map.into_values().collect::<Vec<_>>();
results.sort_by(|a, b| a.name.cmp(&b.name));
Ok(results)
}
/// Convenience function to parse a font from a pattern, and log appropriately
/// if the parsing fails.
/// If `validate` is set to [`ValidateFontSupportsEn::Yes`] an error is returned if the font does not support
/// english.
fn parse_font(
pattern: Pattern<'_>,
validate: ValidateFontSupportsEn,
) -> Result<FontHandle, Error> {
FontHandle::try_from_pattern(&pattern, validate).map_err(|err| {
let font_name = pattern.name().unwrap_or("unknown");
match &err {
Error::InvalidFontFormat(_) | Error::DoesNotSupportEn => {
log::debug!("skipping font {font_name} because of error: {err:#}")
}
_ => {
log::warn!("could not parse font {font_name}: {err:#}");
}
};
err
})
}
/// Returns a list of fallback fonts that match the `family_name` and given `properties`, in order of closeness.
pub fn fallback_fonts(
&self,
family_name: &str,
properties: Properties,
) -> Result<Vec<FontHandle>, Error> {
let mut pattern = Pattern::new(&self.fc);
// Though unlikely, return an `Error` if the requested family name has a null character in it.
let name = CString::new(family_name)
.map_err(|_| Error::InvalidFontName(family_name.to_string()))?;
pattern.add_string(FC_FAMILY, &name);
pattern.add_integer(FC_WEIGHT, to_fontconfig_weight(properties.weight));
pattern.add_integer(FC_SLANT, to_fontconfig_style(properties.style));
let mut object_set = ObjectSet::new(&self.fc);
object_set.add(FC_FAMILY);
object_set.add(FC_FULLNAME);
object_set.add(FC_FILE);
object_set.add(FC_INDEX);
// By setting trim to true, we omit fonts that have a unicode range covered by prior fonts in chain. Doing this
// reduces the overall set of fallback fonts we need to load.
let sort_fonts = sort_fonts(&pattern, true /* trim */);
// Skip the first font, since this is considered the primary "font" we're trying to match.
let fallback_fonts = sort_fonts
.iter()
.skip(1)
.filter_map(|pattern| {
// Fallback fonts we load aren't guaranteed to support english.
// Also, parse_font already has logging for parsing, so we log there.
Self::parse_font(pattern, ValidateFontSupportsEn::No).ok()
})
.collect_vec();
Ok(fallback_fonts)
}
fn query_fonts(&self, family_name: Option<&str>) -> Result<FontSet<'_>, Error> {
let mut pattern = Pattern::new(&self.fc);
if let Some(name) = family_name {
// Very unlikely that someone is going to pass a font name with a \0 in,
// but covering just in case w/ an error
let name = CString::new(name).map_err(|_| Error::InvalidFontName(name.to_string()))?;
pattern.add_string(FC_FAMILY, &name)
}
let mut object_set = ObjectSet::new(&self.fc);
object_set.add(FC_FAMILY);
object_set.add(FC_FULLNAME);
object_set.add(FC_FILE);
object_set.add(FC_INDEX);
object_set.add(FC_SPACING);
object_set.add(FC_LANG);
object_set.add(FC_FONTFORMAT);
Ok(list_fonts(&pattern, Some(&object_set)))
}
}
impl FontHandle {
/// Attempts to generate a FontHandle from a Fontconfig Pattern.
///
/// In order to properly parse out a FontHandle, the pattern needs to have
///
/// * A filename
/// * a face_index
///
/// If either of these fields are missing, an Error::MissingMetadataField will
/// be returned
///
/// Additionally, will return the following errors:
///
/// * Error::DoesNotSupportEn: if the pattern is missing en as a supported language and `validate_fonts_support_en`
/// is set to [`ValidateFontSupportsEn::Yes`].
/// * Error::InvalidFontFormat: if the pattern's font format is not TTF or CFF.
fn try_from_pattern(
value: &Pattern<'_>,
validate_font_supports_en: ValidateFontSupportsEn,
) -> Result<Self, Error> {
let file_path = value
.filename()
.ok_or_else(|| Error::MissingMetadataField("filename".to_owned()))?;
let index = value
.face_index()
.ok_or_else(|| Error::MissingMetadataField("face_index".to_owned()))?
as u32;
if matches!(validate_font_supports_en, ValidateFontSupportsEn::Yes)
&& !value
.lang_set()
.is_some_and(|lang_set| lang_set.into_iter().any(|lang| lang == "en"))
{
return Err(Error::DoesNotSupportEn);
}
if !matches!(
value.format(),
Ok(fontconfig::FontFormat::TrueType) | Ok(fontconfig::FontFormat::CFF)
) {
// NOTE: fontconfig::FontFormat does not impl Debug or any mapping to strings,
// so for debugging purposes we pull the underlying string field the
// enum is computed from.
let font_format_str = value.get_string(FC_FONTFORMAT).unwrap_or_default();
return Err(Error::InvalidFontFormat(font_format_str.to_string()));
}
let spacing = value.get_int(FC_SPACING);
Ok(FontHandle::new(
file_path,
index,
match spacing {
None => false,
Some(v) => v == FC_MONO,
},
))
}
}
/// A handle containing information necessary to load all font faces in a family.
pub(super) struct FamilyHandle {
name: String,
fonts: Vec<FontHandle>,
}
impl FamilyHandle {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
fonts: vec![],
}
}
pub fn name(&self) -> &str {
&self.name
}
fn add_font(&mut self, font: FontHandle) {
self.fonts.push(font);
}
/// Consumes the Family Handle into a FontFamily object.
pub fn into_family(self) -> Result<FontFamily, Error> {
self.into_info_and_family().map(|(_, family)| family)
}
/// Converts the [`FamilyHandle`] into a [`FontInfo`], [`FontFamily`] pair.
pub fn into_info_and_family(self) -> Result<(FontInfo, FontFamily), Error> {
let mut fonts = Vec::<FontHandle>::with_capacity(self.fonts.len());
let mut errors = Vec::<Error>::new();
let mut is_monospace = false;
let name = self.name;
for handle in self.fonts {
match handle.validate_font_data() {
Ok(_) => {
is_monospace |= handle.is_monospace();
fonts.push(handle);
}
Err(err) => errors.push(Error::FontData(err)),
}
}
if !fonts.is_empty() {
Ok((
FontInfo {
family_name: name.clone(),
is_monospace,
},
FontFamily { fonts, name },
))
} else {
Err(Error::FamilyHasNoFonts(name, errors))
}
}
}
/// Errors associated with loading fonts.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The FontLoader cannot be initialized b/c the underlying
/// Fontconfig ffi handle failed to init.
#[error("Failed to initialize Fontconfig ffi handle")]
Init,
/// The user has passed a malformed CString font name.
#[error("Invalid Font Name {0}")]
InvalidFontName(String),
/// A font could not be parsed into a handle b/c it is missing
/// an important metadata field.
#[error("Could not parse font, missing metadata field {0}")]
MissingMetadataField(String),
/// A font does not have a valid font format (either TTF or CFF)
#[error("Invalid font format '{0}'")]
InvalidFontFormat(String),
/// A font does not support the language en
#[error("Font does not support language en")]
DoesNotSupportEn,
/// A font family has been requested, but there are no valid
/// fonts for that family
#[error("Font family {0} does not contain any valid fonts")]
FamilyHasNoFonts(String, Vec<Error>),
// When the underlying font handle has trouble loading data.
#[error("Failed to load font data")]
FontData(#[from] FontDataError),
}
fn to_fontconfig_weight(weight: Weight) -> c_int {
match weight {
Weight::Thin => FC_WEIGHT_THIN,
Weight::ExtraLight => FC_WEIGHT_EXTRALIGHT,
Weight::Light => FC_WEIGHT_LIGHT,
Weight::Normal => FC_WEIGHT_NORMAL,
Weight::Medium => FC_WEIGHT_MEDIUM,
Weight::Semibold => FC_WEIGHT_SEMIBOLD,
Weight::Bold => FC_WEIGHT_BOLD,
Weight::ExtraBold => FC_WEIGHT_EXTRABOLD,
Weight::Black => FC_WEIGHT_BLACK,
}
}
fn to_fontconfig_style(style: Style) -> c_int {
match style {
Style::Normal => FC_SLANT_ROMAN,
Style::Italic => FC_SLANT_ITALIC,
}
}
@@ -0,0 +1,57 @@
//! Module containing the definition of [`StrIndexMap`], allowing for repeated, efficient conversion
//! between a byte and/or char index from a backing `str`.
use std::collections::HashMap;
/// Map that provides efficient conversion from byte <-> char index from a backing `str`.
/// See [`StrIndexMap::byte_index`] and [`StrIndexMap::char_index`] for conversion functions to
/// convert from/to a byte index to a char index.
pub(super) struct StrIndexMap {
byte_to_char_index: HashMap<usize, usize>,
char_to_byte_index: Vec<usize>,
}
impl StrIndexMap {
/// Constructs a new [`StrIndexMap`] with byte <-> char indices based on the input `str`.
/// NOTE this runs in O(n) time as it requires walking through each char index in the `str`.
pub(super) fn new(str: impl AsRef<str>) -> Self {
let char_indices = str.as_ref().char_indices();
let (_, upper_bound) = char_indices.size_hint();
let (mut byte_to_char_index, mut char_to_byte_index) = match upper_bound {
None => (HashMap::new(), Vec::new()),
Some(size) => (HashMap::with_capacity(size), Vec::with_capacity(size)),
};
for (char_index, (byte_index, _)) in char_indices.enumerate() {
byte_to_char_index.insert(byte_index, char_index);
char_to_byte_index.push(byte_index);
}
Self {
byte_to_char_index,
char_to_byte_index,
}
}
/// Returns the _byte_ index of the string at the given `char_index`. If the `char_index` does
/// not exist in the string, `None` is returned.
pub(super) fn byte_index(&self, char_index: usize) -> Option<usize> {
self.char_to_byte_index.get(char_index).copied()
}
/// Returns the _char_ index of the string at the given byte index. If the `byte_index` does not
/// exist in the string or if it does not lie at a char boundary, `None` is returned.
pub(super) fn char_index(&self, byte_index: usize) -> Option<usize> {
self.byte_to_char_index.get(&byte_index).copied()
}
/// Returns the total number of characters in the string.
pub(super) fn num_chars(&self) -> usize {
self.char_to_byte_index.len()
}
}
#[cfg(test)]
#[path = "str_index_map_tests.rs"]
mod tests;
@@ -0,0 +1,46 @@
use super::*;
#[test]
fn test_str_index_map_get_byte_index() {
let text = "ab😈■d";
let str_index_map = StrIndexMap::new(text);
assert_eq!(str_index_map.byte_index(0), Some(0));
assert_eq!(str_index_map.byte_index(1), Some(1));
assert_eq!(str_index_map.byte_index(2), Some(2));
// The character at index 2 (😈) is 4 bytes, which means the character at index 3 (■) starts at
// byte index 6.
assert_eq!(str_index_map.byte_index(3), Some(6));
// The character at index 3 (■) is 3 bytes, which means the character at index 4 (d) starts at
// byte index 9.
assert_eq!(str_index_map.byte_index(4), Some(9));
// The backing string only has 5 characters. Ensure we return None in the case a character index
// that isn't included in the string is passed.
assert_eq!(str_index_map.byte_index(5), None);
}
#[test]
fn test_str_index_map_get_char_index() {
let text = "ab😈■d";
let str_index_map = StrIndexMap::new(text);
assert_eq!(str_index_map.char_index(0), Some(0));
assert_eq!(str_index_map.char_index(1), Some(1));
assert_eq!(str_index_map.char_index(2), Some(2));
// The character at index 2 (😈) is 4 bytes, which means the character at index 3 (■) starts at
// byte index 6.
assert_eq!(str_index_map.char_index(6), Some(3));
// The character at index 3 (■) is 3 bytes, which means the character at index 4 (d) starts at
// byte index 9.
assert_eq!(str_index_map.char_index(9), Some(4));
// The backing string only has 10 bytes. Ensure we return None in the case a byte index
// that isn't included in the string is passed.
assert_eq!(str_index_map.char_index(10), None);
// Byte index 3 is not a char boundary, so we should return None.
assert_eq!(str_index_map.char_index(3), None);
}
@@ -0,0 +1,135 @@
//! Module that rasterizes text using `swash`.
use crate::fonts::canvas::{Canvas, RasterFormat};
use crate::fonts::{FontId, GlyphId, RasterizedGlyph, SubpixelAlignment};
use crate::platform::FontDB as _;
use crate::rendering::GlyphConfig;
use crate::windowing::winit::fonts::FontDB;
use anyhow::{anyhow, Result};
use cosmic_text::{CacheKey, CacheKeyFlags};
use pathfinder_geometry::rect::RectI;
use pathfinder_geometry::vector::{vec2i, Vector2F, Vector2I};
impl FontDB {
pub(super) fn glyph_raster_bounds(
&self,
font_id: FontId,
size: f32,
glyph_id: GlyphId,
scale: Vector2F,
_glyph_config: &GlyphConfig,
) -> Result<RectI> {
let Ok(_typographic_bounds) = self
.glyph_typographic_bounds(font_id, glyph_id)
.map(|bounds| bounds.to_f32())
else {
// We can't render this glyph using this font, return an empty rect to indicate we
// don't need to rasterize this glyph. This can happen if the font doesn't contain
// a glyph _or_ if the glyph isn't renderable (some fonts contain a glyph for the
// space character, but don't provide outlines for it).
return Ok(RectI::new(Vector2I::zero(), Vector2I::zero()));
};
let id = *self
.text_layout_system
.font_id_map
.read()
.get_by_left(&font_id)
.unwrap();
let image = self
.swash_cache
.write()
.get_image_uncached(
&mut self.text_layout_system.font_store.write(),
CacheKey::new(
id,
glyph_id as u16,
size * scale.x(),
(0., 0.),
CacheKeyFlags::empty(),
)
.0,
)
.clone()
.ok_or_else(|| anyhow!("Failed to get raster image"))?;
let origin = vec2i(image.placement.left, -image.placement.top);
let size = vec2i(image.placement.width as i32, image.placement.height as i32);
Ok(RectI::new(origin, size))
}
#[allow(clippy::too_many_arguments)]
pub(super) fn rasterize_glyph(
&self,
font_id: FontId,
size: f32,
glyph_id: GlyphId,
scale: Vector2F,
subpixel_alignment: SubpixelAlignment,
glyph_config: &GlyphConfig,
requested_format: RasterFormat,
) -> Result<RasterizedGlyph> {
let raster_bounds =
self.glyph_raster_bounds(font_id, size, glyph_id, scale, glyph_config)?;
let id = *self
.text_layout_system
.font_id_map
.read()
.get_by_left(&font_id)
.unwrap();
// Get the raster image without caching--the parent FontDB handles all caching for us.
let image = self
.swash_cache
.write()
.get_image_uncached(
&mut self.text_layout_system.font_store.write(),
CacheKey::new(
id,
glyph_id as u16,
size * scale.x(),
(subpixel_alignment.to_offset().x(), 0.),
CacheKeyFlags::empty(),
)
.0,
)
.clone()
.unwrap();
let (original_format, is_color) = match image.content {
cosmic_text::SwashContent::Mask => (RasterFormat::A8, false),
cosmic_text::SwashContent::SubpixelMask => (RasterFormat::Rgba32, false),
cosmic_text::SwashContent::Color => (RasterFormat::Rgba32, true),
};
// Ensure the pixmap is in the correct requested format (in practice this converts A8 to
// RGBA32).
// TODO(alokedesai): Ensure our font rasterization code is robust to returned formats that
// are different than incoming formats. Right now, we create text bounds based on the
// _incoming_ format.
let pixmap = if original_format == RasterFormat::A8 {
let bytes_per_pixel = requested_format.bytes_per_pixel() as usize;
let mut pixmap = Vec::with_capacity(image.data.len() * bytes_per_pixel);
for byte in image.data {
for _ in 0..bytes_per_pixel {
pixmap.push(byte);
}
}
pixmap
} else {
image.data
};
let canvas = Canvas {
pixels: pixmap,
size: raster_bounds.size(),
row_stride: image.placement.width as usize * original_format.bytes_per_pixel() as usize,
format: RasterFormat::Rgba32,
};
anyhow::Ok(RasterizedGlyph {
canvas,
is_emoji: is_color,
})
}
}
@@ -0,0 +1,129 @@
use super::str_index_map::StrIndexMap;
use crate::fonts::FontId;
use crate::text_layout::{Glyph, Run, TextStyle};
use cosmic_text::LayoutGlyph;
use pathfinder_geometry::vector::vec2f;
/// Helper struct to construct [`Run`]s from a series of shaped glyphs.
pub(super) struct RunBuilder<'a> {
runs: Vec<Run>,
font_in_current_run: FontId,
current_run_style: TextStyle,
current_run_width: f32,
glyphs_in_current_run: Vec<Glyph>,
styles_map: &'a TextStylesMap,
str_index_map: &'a StrIndexMap,
}
impl<'a> RunBuilder<'a> {
pub(super) fn new(
styles_map: &'a TextStylesMap,
initial_font_id: FontId,
str_index_map: &'a StrIndexMap,
) -> Self {
Self {
runs: vec![],
font_in_current_run: initial_font_id,
current_run_style: TextStyle::default(),
current_run_width: 0.0,
glyphs_in_current_run: vec![],
styles_map,
str_index_map,
}
}
/// Reserves space for the provided number of glyphs in the current run.
pub fn reserve_capacity(&mut self, total: usize) {
self.glyphs_in_current_run
.reserve_exact(total.saturating_sub(self.glyphs_in_current_run.capacity()))
}
/// Flushes the current style run by appending the current run into the runs list.
/// NOTE: if there are no glyphs in the run, it is not appended.
fn flush_current_style_run(&mut self) {
if !self.glyphs_in_current_run.is_empty() {
let excess_capacity =
self.glyphs_in_current_run.capacity() - self.glyphs_in_current_run.len();
let mut new_glyphs = Vec::with_capacity(excess_capacity);
std::mem::swap(&mut new_glyphs, &mut self.glyphs_in_current_run);
self.runs.push(Run {
font_id: self.font_in_current_run,
glyphs: new_glyphs,
styles: self.current_run_style,
width: self.current_run_width,
});
}
}
/// Pushes a new laid out glyph into the `RunBuilder`. Internally, `font_id_fn` will be called
/// to get the `FontId` for the `glyph`.
pub(super) fn push_glyph<F: FnOnce(&fontdb::ID) -> FontId>(
&mut self,
glyph: LayoutGlyph,
font_id_fn: F,
) {
let font_id = font_id_fn(&glyph.font_id);
let text_style = self.styles_map.get(glyph.metadata);
// A run is a series of continuous glyphs that have the same style. We use the combination
// of font id (which is a proxy of the font properties such as bold or italic) and the
// `TextStyle` to determine when a new run should be created.
if font_id != self.font_in_current_run || text_style != self.current_run_style {
self.flush_current_style_run();
self.current_run_width = 0.;
self.current_run_style = text_style;
self.font_in_current_run = font_id;
}
let glyph_char_index = self
.str_index_map
.char_index(glyph.start)
.unwrap_or_else(|| self.str_index_map.num_chars());
self.glyphs_in_current_run.push(Glyph {
id: glyph.glyph_id as u32,
position_along_baseline: vec2f(glyph.x, glyph.y),
index: glyph_char_index,
width: glyph.w,
});
self.current_run_width += glyph.w;
}
/// Returns the final list of [`Run`]s that were computed.
pub(super) fn build(mut self) -> Vec<Run> {
self.flush_current_style_run();
self.runs
}
}
/// Simple map that maps an index to a [`TextStyle`].
/// [`cosmic_text`] only supports setting a `usize` as metadata, so this struct is used to generate
/// a mapping of an index to its corresponding `TextStyle`.
///
/// Though this is modeled internally as a `Vec`, use a new type to limit the API since some
/// functions on a `Vec` (such as reordering) would break the mapping of index to text style.
pub(super) struct TextStylesMap {
styles: Vec<TextStyle>,
}
impl TextStylesMap {
pub(super) fn insert(&mut self, text_style: TextStyle) -> usize {
let size = self.styles.len();
self.styles.push(text_style);
size
}
/// Gets the [`TextStyle`] at the given index. If no style is at the index, a default
/// `TextStyle` is returned.
pub(super) fn get(&self, index: usize) -> TextStyle {
self.styles.get(index).copied().unwrap_or_default()
}
pub(super) fn new() -> Self {
Self {
styles: Default::default(),
}
}
}
@@ -0,0 +1,266 @@
use super::{
font_handle::FontHandle, FontFamily, LoadedSystemFonts, TextLayoutSystem,
ValidateFontSupportsEn,
};
use crate::fonts::FontId;
use anyhow::Result;
use font_kit::loader::Loader as _;
use font_kit::{
family_name::FamilyName as FKFamilyName, properties::Properties as FKProperties,
properties::Style as FKStyle, properties::Weight as FKWeight, source::SystemSource as FKSource,
};
use itertools::Itertools;
use owned_ttf_parser::OwnedFace;
use std::collections::HashMap;
use std::sync::Arc;
const EN_US_LOCALE: &str = "en-US";
/// Windows symbol fonts that are used to render window control icons. We specifically do not do any
/// validation of these fonts (i.e. to check if the font contains english characters).
const SYMBOL_ICON_FONTS: &[&str] = &["Segoe Fluent Icons", "Segoe MDL2 Assets"];
pub(crate) mod loader {
use crate::fonts::FontInfo;
use super::*;
pub fn load_all_system_fonts() -> LoadedSystemFonts {
let source = font_kit::source::SystemSource::new();
let fonts = match source.all_fonts() {
Ok(fonts) => fonts,
Err(err) => {
log::warn!("unable to retrieve all fonts from DirectWrite source: {err:?}");
return LoadedSystemFonts(vec![]);
}
};
let mut family_map = HashMap::new();
for font_handle in fonts.into_iter() {
if let Ok(font) = font_handle.load() {
let family_name = font.family_name();
let is_monospace = font.is_monospace();
if font.glyph_for_char('m').is_none() {
// Only allow the user to select fonts that have an English character set.
log::debug!("skipping family {family_name:?} because no 'm' glyph was found");
continue;
}
// Convert font_kit::Handle into UI framework-specific FontHandle.
let font_handle = match font_handle {
font_kit::handle::Handle::Path { path, font_index } => {
FontHandle::new(path, font_index, is_monospace)
}
font_kit::handle::Handle::Memory { bytes, font_index } => {
let owned_face_result = match Arc::try_unwrap(bytes) {
// If we can ensure ownership of the bytes, create an OwnedFace without copying.
Ok(owned_bytes) => OwnedFace::from_vec(owned_bytes, font_index),
// If we can't get sole ownership, create on OwnedFace from a copy the bytes
// (created by .to_vec()).
Err(shared_bytes) => {
OwnedFace::from_vec(shared_bytes.to_vec(), font_index)
}
};
match owned_face_result {
Ok(typeface) => FontHandle::from(typeface),
Err(err) => {
// If we can't parse the typeface, skip it.
log::warn!(
"unable to parse typeface from family {family_name}: {err:?}"
);
continue;
}
}
}
};
let (entry_info, entry_family) = family_map
.entry(family_name.clone())
.or_insert_with(move || {
(
FontInfo {
family_name: family_name.clone(),
is_monospace,
},
FontFamily {
name: family_name,
fonts: vec![],
},
)
});
entry_info.is_monospace |= is_monospace;
entry_family.fonts.push(font_handle);
}
}
LoadedSystemFonts(family_map.into_values().collect_vec())
}
pub fn load_system_font(font_family: &str) -> Result<FontFamily> {
let source = font_kit::source::SystemSource::new();
let family = source.select_family_by_name(font_family)?;
let validate_supports_en = if SYMBOL_ICON_FONTS.contains(&font_family) {
ValidateFontSupportsEn::No
} else {
ValidateFontSupportsEn::Yes
};
Ok(FontFamily {
name: font_family.to_string(),
fonts: family
.fonts()
.iter()
.flat_map(|font_kit_handle| {
load_font_from_handle(font_kit_handle, validate_supports_en)
})
.collect_vec(),
})
}
}
impl TextLayoutSystem {
/// Given a specific character and FontID, find alternate system fonts that can
/// render that character.
pub fn get_fallback_fonts_for_character(
&self,
character: char,
font_id: FontId,
) -> Result<Vec<FontId>> {
// Retrieve the font's family name and properties from the font store.
// First, find the font's fontdb ID.
let &original_font_id =
self.font_id_map
.read()
.get_by_left(&font_id)
.ok_or(anyhow::format_err!(
"No left entry found for {font_id:?} in font_id_map"
))?;
let (style, weight, family_name) = self.get_font_info_from_store(original_font_id)?;
let source = FKSource::new();
let style = match style {
fontdb::Style::Normal => FKStyle::Normal,
fontdb::Style::Italic => FKStyle::Italic,
fontdb::Style::Oblique => FKStyle::Oblique,
};
let weight = FKWeight(weight.0 as f32);
let properties = FKProperties {
style,
weight,
stretch: Default::default(),
};
let font_handle = source
.select_best_match(
&[
FKFamilyName::Title(family_name.to_owned()),
FKFamilyName::Monospace,
],
&properties,
)
.map_err(|err| anyhow::anyhow!("Didn't find {family_name} in fontdb: {err}"))?;
// Load fallback fonts for the requested character.
let loaded_font = font_handle.load().map_err(|err| {
anyhow::anyhow!("Unable to load typeface from font_kit Handle: {err:?}")
})?;
let fallback_result =
loaded_font.get_fallbacks(character.to_string().as_str(), EN_US_LOCALE);
// Convert each font-kit fallback `Font` into a UI framework `FontHandle` and load it into
// fontdb. We deliberately avoid `font_kit::Font::handle()` here: its default impl reads
// the full font file into an `Arc<Vec<u8>>` and returns a `Handle::Memory` with
// `font_index` hard-coded to `0` (see the FIXME at font-kit/src/loader.rs:172), which
// bypasses `TextLayoutSystem::insert_font`'s path-based dedup and loses TTC face indices.
// Instead we reach through `NativeFont` to the underlying `IDWriteFontFace` and recover
// the on-disk file path + real face index, the same way
// `DirectWriteSource::create_handle_from_dwrite_font` does for enumerated system fonts.
// This lets fontdb mmap the file lazily and lets `insert_font` dedup by `(path, index)`,
// so the same fallback family is loaded at most once per process.
let fallback_font_vec = fallback_result
.fonts
.into_iter()
.flat_map(|fallback_font| {
let loaded_handle =
fallback_font_path_handle(&fallback_font.font).or_else(|| {
// Last-resort fallback for fonts that aren't backed by a local file (e.g.
// custom collection loaders). These don't appear in practice for DirectWrite
// system fallbacks, but preserve the original byte-copy behavior so we
// degrade gracefully instead of dropping the glyph.
let handle = fallback_font.font.handle()?;
load_font_from_handle(&handle, ValidateFontSupportsEn::No).ok()
})?;
self.insert_font(loaded_handle).ok()
})
.collect_vec();
Ok(fallback_font_vec)
}
/// Critical section for fetching the font style, weight and family name from fontdb.
/// This function performs the minimum work required to fetch this information from
/// fontdb to minimize the amount of time spent holding a read lock on the font store.
fn get_font_info_from_store(
&self,
font_id: fontdb::ID,
) -> Result<(fontdb::Style, fontdb::Weight, String)> {
let store_read_lock = self.font_store.read();
let db_read = store_read_lock.db();
let face = db_read.face(font_id).ok_or(anyhow::anyhow!(
"Unable to retrieve font face from fontdb font_store"
))?;
let style = face.style;
let weight = face.weight;
let Some(en_us_family_info) = face.families.first() else {
return Err(anyhow::anyhow!("Font face doesn't have any family names"));
};
let (family_name, _) = en_us_family_info;
// Clone the family name because it's protected by the font store's RWLock.
Ok((style, weight, family_name.to_owned()))
}
}
fn load_font_from_handle(
font_handle: &font_kit::handle::Handle,
validate_supports_en_charset: ValidateFontSupportsEn,
) -> Result<FontHandle> {
let font = font_handle.load()?;
let is_monospace = font.is_monospace();
if matches!(validate_supports_en_charset, ValidateFontSupportsEn::Yes) {
font.glyph_for_char('m').ok_or(anyhow::format_err!(
"No 'm' glyph found for font {}",
font.full_name()
))?;
}
match font_handle {
font_kit::handle::Handle::Path { path, font_index } => {
Ok(FontHandle::new(path, *font_index, is_monospace))
}
font_kit::handle::Handle::Memory { bytes, font_index } => {
let typeface = OwnedFace::from_vec(bytes.to_vec(), *font_index)?;
Ok(FontHandle::from(typeface))
}
}
}
/// Builds a path-backed [`FontHandle`] for a font-kit DirectWrite `Font` by reaching through
/// [`font_kit::loaders::directwrite::NativeFont`] to the underlying `IDWriteFontFace`.
///
/// This mirrors what font-kit itself does for enumerated system fonts in
/// `DirectWriteSource::create_handle_from_dwrite_font` (font-kit/src/sources/directwrite.rs:103),
/// and is the reason we carry `dwrote` as a direct dependency: font-kit's generic
/// `Loader::handle()` default returns a `Handle::Memory` with a byte copy of the full file, which
/// we specifically need to avoid on the per-character fallback path.
///
/// Returns `None` when DirectWrite cannot produce a local file path for the font, i.e. the font
/// was loaded via a custom collection loader or backed only by an in-memory stream. For system
/// fallback fonts returned by `IDWriteFontFallback::MapCharacters` against the system font
/// collection, a path is always available.
fn fallback_font_path_handle(font: &font_kit::loaders::directwrite::Font) -> Option<FontHandle> {
let native = font.native_font();
let file = native.dwrite_font_face.files().ok()?.into_iter().next()?;
let path = file.font_file_path().ok()?;
let font_index = native.dwrite_font_face.get_index();
Some(FontHandle::new(path, font_index, font.is_monospace()))
}
@@ -0,0 +1,171 @@
//! Linux-specific app level functionality for use with `winit`.
//!
//! For more information about X11 extensions and request codes/opcodes,
//! see: https://www.x.org/wiki/Development/Documentation/Protocol/OpCodes.
use lazy_static::lazy_static;
use std::sync::{Arc, Mutex};
use wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
use winit::event_loop::EventLoop;
use x11rb::protocol::xproto::ConnectionExt as _;
lazy_static! {
static ref ENCOUNTERED_BAD_MATCH_FROM_DRI3_FENCE_FROM_FD: Arc<Mutex<bool>> = Default::default();
}
/// Returns whether a `BadMatch` was returned from a `DRI3FenceFromFd` request.
/// NOTE calling this function resets internal state. Subsequent calls to this function will return
/// false until a new `BadMatch` error is encountered for the aforementioned request.
pub fn take_encountered_bad_match_from_dri3_fence_from_fd() -> bool {
let Ok(mut guard) = ENCOUNTERED_BAD_MATCH_FROM_DRI3_FENCE_FROM_FD.lock() else {
return false;
};
std::mem::take(&mut guard)
}
/// Registers an xlib error hook with winit, if needed.
pub fn maybe_register_xlib_error_hook<T>(event_loop: &EventLoop<T>) {
if !is_x11(event_loop) {
return;
}
let extension_info_map = get_x11_extension_info_map();
// Register a callback function with winit that we can use to
// observe and consume error events from the Xlib event loop.
// This returns a boolean indicating whether or not it was
// "handled" by this error hook. If `true` is returned,
// winit will ignore the error.
winit::platform::x11::register_xlib_error_hook(Box::new(move |_, error| {
static GRAB_KEY_REQUEST_CODE: u8 = 33;
static PRESENT_PIXMAP_MINOR_OPCODE: u8 = 1;
/// Minor opcode for the `DRI3FenceFromFD` request within the DRI3 extension.
/// See https://cgit.freedesktop.org/xorg/proto/dri3proto/tree/dri3proto.txt.
static DRI3_FENCE_FROM_FD_MINOR_OPCODE: u8 = 4;
let Some(error) = std::ptr::NonNull::new(error as *mut x11_dl::xlib::XErrorEvent) else {
return false;
};
let error = unsafe { error.as_ref() };
// Ignore errors due to global-hotkey attempting to register a
// hotkey that's already been registered.
if error.error_code == x11_dl::xlib::BadAccess
&& error.request_code == GRAB_KEY_REQUEST_CODE
{
return true;
}
let Some(extension_info) = extension_info_map.get(&error.request_code) else {
// If we can't get information about the extension, let winit
// handle it. It will log the error, so we don't need to.
return false;
};
// If there's a BadWindow error from a PresentPixmap
// request, ignore it - this is a known bug in Mesa.
if error.error_code == x11_dl::xlib::BadWindow
&& extension_info.name == "Present"
&& error.minor_code == PRESENT_PIXMAP_MINOR_OPCODE
{
log::warn!("Ignoring BadWindow error from PresentPixmap request");
return true;
}
// Specifically handle a `BadMatch` from a `DRI3_FENCE_FROM_FD` request. From error
// reporting, we only seem to get this error when a user has the Performance PRIME profile
// enabled (indicating to NVIDIA Optimus that the NVIDIA GPU should always be used).
if error.error_code == x11_dl::xlib::BadMatch
&& extension_info.name == "DRI3"
&& error.minor_code == DRI3_FENCE_FROM_FD_MINOR_OPCODE
{
log::warn!("Ignoring a BadMatch from a DRI3FenceFromFD request. The NVIDIA Performance PRIME profile is likely enabled.");
*ENCOUNTERED_BAD_MATCH_FROM_DRI3_FENCE_FROM_FD
.lock()
.unwrap() = true;
return true;
}
// For other errors from requests defined in extensions, log some
// relevant extension information, then let winit decide what to do
// with it. winit will log an error if we don't handle it, hence only logging a warning
// here.
log::warn!(
"Detected X11 error in {} extension (major opcode: {}; first error: {})",
extension_info.name,
error.request_code,
extension_info.first_error,
);
if *ENCOUNTERED_BAD_MATCH_FROM_DRI3_FENCE_FROM_FD
.lock()
.expect("Mutex should not be poisoned")
&& extension_info.name == "Present"
{
log::warn!("Ignoring an error from the PRESENT extension after catching a BadMatch from a DRI3FenceFromFD request. Minor opcode: {}; Error code: {}",
error.minor_code,
error.error_code);
return true;
}
false
}));
}
/// Queries the X11 server to get information about which extensions are
/// available and metadata about them.
fn get_x11_extension_info_map() -> std::collections::HashMap<u8, X11ExtensionInfo> {
let mut extension_map = Default::default();
let Ok((xcb, _)) = x11rb::rust_connection::RustConnection::connect(None) else {
return extension_map;
};
let Ok(cookie) = xcb.list_extensions() else {
return extension_map;
};
let Ok(extensions) = cookie.reply() else {
return extension_map;
};
extensions.names.iter().for_each(|name| {
if let Ok(cookie) = xcb.query_extension(&name.name) {
if let Ok(result) = cookie.reply() {
if let Ok(name) = String::from_utf8(name.name.clone()) {
extension_map.insert(
result.major_opcode,
X11ExtensionInfo {
name,
first_error: result.first_error,
},
);
}
}
}
});
extension_map
}
/// A collection of information about an X11 extension.
struct X11ExtensionInfo {
/// The name of the extension.
name: String,
/// The ID offset applied to errors defined in this extension.
first_error: u8,
}
/// Returns whether or not the provided event loop is using X11 as the
/// underlying platform implementation.
fn is_x11<T>(event_loop: &EventLoop<T>) -> bool {
matches!(
event_loop
.owned_display_handle()
.display_handle()
.map(|dh| dh.as_raw()),
Ok(RawDisplayHandle::Xlib(_)) | Ok(RawDisplayHandle::Xcb(_))
)
}
@@ -0,0 +1,208 @@
use std::ops::Not;
use arboard::{
self, Clipboard as LinuxClipboardInner, GetExtLinux, LinuxClipboardKind, SetExtLinux,
};
use zbus::zvariant::NoneValue;
use crate::{clipboard::ClipboardContent, Clipboard};
pub struct LinuxClipboard {
inner: LinuxClipboardInner,
}
impl LinuxClipboard {
pub fn new() -> Result<Self, arboard::Error> {
Ok(Self {
inner: LinuxClipboardInner::new()?,
})
}
}
impl Clipboard for LinuxClipboard {
fn write(&mut self, contents: ClipboardContent) {
if let Err(err) = self.write_to_specific_clipboard(LinuxClipboardKind::Clipboard, &contents)
{
if contents.html.is_some() {
log::warn!("Unable to set clipboard HTML: {err:?}");
} else {
log::warn!("Unable to set clipboard text: {err:?}");
}
}
}
fn read(&mut self) -> ClipboardContent {
match self.read_from_specific_clipboard(LinuxClipboardKind::Clipboard) {
Ok(content) => content,
Err(err) => {
log::warn!("Failed to read from Linux clipboard: {err:?}");
ClipboardContent::null_value()
}
}
}
fn write_to_primary_clipboard(&mut self, contents: ClipboardContent) {
match self.write_to_specific_clipboard(LinuxClipboardKind::Primary, &contents) {
Ok(_) => (),
Err(arboard::Error::ClipboardNotSupported) => {
log::info!(
"Primary clipboard is not supported, falling back to default clipboard."
);
// Try the default clipboard.
self.write(contents);
}
Err(err) => {
if contents.html.is_some() {
log::warn!("Unable to set primary clipboard HTML: {err:?}");
} else {
log::warn!("Unable to set primary clipboard text: {err:?}");
}
}
}
}
fn read_from_primary_clipboard(&mut self) -> ClipboardContent {
match self.read_from_specific_clipboard(LinuxClipboardKind::Primary) {
Ok(content) => content,
Err(arboard::Error::ClipboardNotSupported) => {
log::info!(
"Primary clipboard is not supported, falling back to default clipboard."
);
// Try the default clipboard.
match self.read_from_specific_clipboard(LinuxClipboardKind::Clipboard) {
Ok(content) => content,
Err(err) => {
log::warn!("Unable to read from primary clipboard fallback: {err:?}");
ClipboardContent::null_value()
}
}
}
Err(err) => {
log::warn!("Unable to read from primary clipboard: {err:?}");
ClipboardContent::null_value()
}
}
}
}
impl LinuxClipboard {
/// Parses Linux clipboard text for absolute file paths.
///
/// When copying files, Linux file managers typically place the paths as text content onto
/// the clipboard. We parse this text to extract absolute paths, but if ANY line is not an
/// absolute path, we assume this is regular text content and return None (no paths).
fn parse_valid_filepaths_from_text(&mut self, text_content: &str) -> Option<Vec<String>> {
let mut file_paths = Vec::new();
// Check for absolute filepaths
for line in text_content.trim().lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let candidate_path_str = if let Some(uri_path) = line.strip_prefix("file://") {
match urlencoding::decode(uri_path) {
Ok(decoded_path) => decoded_path.into_owned(),
Err(_) => uri_path.to_string(),
}
} else {
line.to_string()
};
let candidate_path = std::path::Path::new(&candidate_path_str);
if candidate_path.is_absolute() && candidate_path.exists() {
file_paths.push(candidate_path_str);
} else {
// Not an absolute-path indicates the text was not from copying files, so return
return None;
}
}
if file_paths.is_empty() {
None
} else {
Some(file_paths)
}
}
/// Reads clipboard content from a specific clipboard buffer.
fn read_from_specific_clipboard(
&mut self,
clipboard_kind: LinuxClipboardKind,
) -> Result<ClipboardContent, arboard::Error> {
let text_result = self.inner.get().text();
let mut content = ClipboardContent {
plain_text: text_result.as_ref().map(|s| s.clone()).unwrap_or_default(),
..Default::default()
};
// Get file paths from clipboard (Linux-specific)
content.paths = self.parse_valid_filepaths_from_text(&content.plain_text);
// Attempt to use HTML data first.
match self.inner.get().clipboard(clipboard_kind).html() {
Ok(html) => {
content.html = html.is_empty().not().then_some(html);
// Try to get image content from clipboard
content.images = crate::clipboard_utils::read_images_from_clipboard(
&mut self.inner,
&content.html,
&content.plain_text,
);
return Ok(content);
}
Err(err) => {
log::info!(
"Unable to read HTML from clipboard: {err:?}, falling back to plaintext."
);
}
}
// Fallback to using plaintext
content.images = crate::clipboard_utils::read_images_from_clipboard(
&mut self.inner,
&None, // No HTML in fallback case
&content.plain_text,
);
// Return success if we have ANY content (text, paths, OR images)
// Only error if ALL content types failed
if text_result.is_ok()
|| content
.paths
.as_ref()
.is_some_and(|paths| !paths.is_empty())
|| content.images.as_ref().is_some_and(|imgs| !imgs.is_empty())
{
Ok(content)
} else {
// All content types failed - return the text error
text_result.map(|_| content)
}
}
fn write_to_specific_clipboard(
&mut self,
clipboard_kind: LinuxClipboardKind,
contents: &ClipboardContent,
) -> Result<(), arboard::Error> {
if let Some(html) = &contents.html {
self.inner
.set()
.clipboard(clipboard_kind)
.html(html, Some(&contents.plain_text))
} else {
self.inner
.set()
.clipboard(clipboard_kind)
.text(&contents.plain_text)
}
}
}
#[cfg(test)]
#[path = "clipboard_tests.rs"]
mod tests;
@@ -0,0 +1,195 @@
/// Linux-specific clipboard tests.
///
/// Note: Most image processing functionality is tested in ui/src/clipboard_utils_tests.rs
/// to avoid duplication. These tests focus on Linux-specific clipboard behavior.
#[cfg(target_os = "linux")]
mod clipboard_tests {
use crate::clipboard::{Clipboard, ClipboardContent};
use crate::windowing::winit::linux::LinuxClipboard;
fn create_test_clipboard() -> Option<LinuxClipboard> {
LinuxClipboard::new().ok()
}
/// Helper function to avoid repetitive clipboard creation and early return logic.
fn with_test_clipboard<F>(test_fn: F)
where
F: FnOnce(&mut LinuxClipboard),
{
let mut clipboard = match create_test_clipboard() {
Some(clipboard) => clipboard,
None => {
eprintln!("Skipping test - no clipboard available (headless environment)");
return;
}
};
test_fn(&mut clipboard);
}
/// Helper to assert that paths are correctly extracted from clipboard text.
fn assert_paths_extracted(
clipboard: &mut LinuxClipboard,
input: &str,
expected_paths: &[&str],
) {
let content = ClipboardContent::plain_text(input.to_string());
clipboard.write(content);
let read_content = clipboard.read();
if let Some(paths) = read_content.paths {
assert_eq!(paths.len(), expected_paths.len());
for expected_path in expected_paths {
assert!(
paths.contains(&expected_path.to_string()),
"Expected path '{expected_path}' not found in: {paths:?}"
);
}
} else {
panic!("Expected to extract paths from: '{input}'");
}
}
/// Helper to assert that no paths are extracted from clipboard text.
fn assert_no_paths_extracted(clipboard: &mut LinuxClipboard, input: &str) {
let content = ClipboardContent::plain_text(input.to_string());
clipboard.write(content);
let read_content = clipboard.read();
assert!(
read_content.paths.is_none(),
"Expected no paths to be extracted from: '{}', but got: {:?}",
input,
read_content.paths
);
}
#[test]
fn test_clipboard_round_trip() {
with_test_clipboard(|clipboard| {
let test_content = ClipboardContent::plain_text("Linux clipboard test".to_string());
// Write content
clipboard.write(test_content.clone());
// Read it back
let read_content = clipboard.read();
// Should get the same text back (in environments where clipboard works)
if !read_content.plain_text.is_empty() {
assert_eq!(read_content.plain_text, test_content.plain_text);
}
});
}
#[test]
fn test_html_content_handling() {
with_test_clipboard(|clipboard| {
let test_content = ClipboardContent {
plain_text: "Test text".to_string(),
html: Some("<div>Test HTML</div>".to_string()),
images: None,
paths: None,
};
// Write HTML content
clipboard.write(test_content.clone());
// Read it back
let read_content = clipboard.read();
// In environments where clipboard works, we should get content back
// (the exact HTML may not be preserved depending on the system)
if !read_content.is_empty() {
assert!(!read_content.plain_text.is_empty());
}
});
}
#[test]
fn test_primary_clipboard_operations() {
with_test_clipboard(|clipboard| {
let test_content = ClipboardContent::plain_text("Primary clipboard test".to_string());
// Test primary clipboard write (should not panic)
clipboard.write_to_primary_clipboard(test_content.clone());
// Test primary clipboard read (should return valid ClipboardContent)
let read_content = clipboard.read_from_primary_clipboard();
// Should always return a ClipboardContent struct, even if empty
// (this tests the fallback behavior when primary clipboard isn't supported)
assert!(matches!(read_content.images, None | Some(_)));
assert!(matches!(read_content.html, None | Some(_)));
});
}
#[test]
fn test_empty_content_handling() {
with_test_clipboard(|clipboard| {
let empty_content = ClipboardContent::plain_text("".to_string());
// Writing empty content should not panic
clipboard.write(empty_content);
// Reading should return valid ClipboardContent (may be empty or have previous content)
let read_content = clipboard.read();
// Should always return a valid ClipboardContent struct
assert!(matches!(read_content.images, None | Some(_)));
});
}
#[test]
fn test_absolute_paths_extracted() {
with_test_clipboard(|clipboard| {
// Test single path
assert_paths_extracted(
clipboard,
"/home/user/document.txt",
&["/home/user/document.txt"],
);
// Test multiple paths
assert_paths_extracted(
clipboard,
"/home/user/file1.txt\n/home/user/file2.pdf",
&["/home/user/file1.txt", "/home/user/file2.pdf"],
);
});
}
#[test]
fn test_file_uri_decoded() {
with_test_clipboard(|clipboard| {
// Test basic file:// URI
assert_paths_extracted(
clipboard,
"file:///home/user/document.txt",
&["/home/user/document.txt"],
);
// Test URL-encoded URI with spaces
assert_paths_extracted(
clipboard,
"file:///home/user/My%20Documents/file.txt",
&["/home/user/My Documents/file.txt"],
);
});
}
#[test]
fn test_non_absolute_paths_rejected() {
with_test_clipboard(|clipboard| {
// Relative paths should be rejected
assert_no_paths_extracted(clipboard, "./relative.txt\n../another.txt");
// Regular text should be rejected
assert_no_paths_extracted(clipboard, "Hello world\nThis is text");
// Mixed content should be rejected (strict policy)
assert_no_paths_extracted(
clipboard,
"/home/user/file.txt\nSome text\n/another/file.txt",
);
});
}
}
@@ -0,0 +1,166 @@
use std::{env, path::PathBuf};
use tini::Ini;
static CURSOR_DIR_NAME: &'static &str = &"cursors";
static CURSOR_INDEX_FILE_NAME: &'static &str = &"index.theme";
static THEME_FILE_CURSOR_SECTION: &'static &str = &"Icon Theme";
static THEME_FILE_INHERITS_KEY: &'static &str = &"Inherits";
static ENV_DATA_DIRS: &'static &str = &"XDG_DATA_DIRS";
static ENV_CURSOR_THEME: &'static &str = &"XCURSOR_THEME";
static DEFAULT_THEME: &'static &str = &"default";
static KNOWN_THEMES: &[&str] = &["Yaru", "Adwaita"];
pub fn ensure_cursor_theme() {
// If the XCURSOR_THEME value is explicitly set,
// then we do not want to modify the user's environment
if env::var(ENV_CURSOR_THEME).is_ok() {
return;
}
let crawler = CursorThemeCrawler::new();
if let Some(theme) = crawler.determine_cursor_theme() {
// winit and it's dependencies will automatically check for
// the default theme, so we do not need to mess with the
// env var here.
if theme != *DEFAULT_THEME {
env::set_var(ENV_CURSOR_THEME, theme);
}
}
}
struct CursorThemeCrawler {
/// Directories to search when looking for a cursor theme.
/// Directories are searched in vec order from first to last.
/// However, because themes can reference other themes, it is
/// possible for search results to traverse multiple directories.
/// For example, a default theme can be found in directories[1]
/// that inherits from a theme in directories[3], which itself
/// inherits from a theme in directories[0]
directories: Vec<PathBuf>,
}
fn non_empty_var(name: &str) -> Option<String> {
env::var(name).ok().filter(|val| !val.is_empty())
}
impl CursorThemeCrawler {
pub fn new() -> Self {
// Per https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html#directory_layout,
// we search:
// - $HOME/.icons (for backwards compatibility)
// - $XDG_DATA_HOME/icons (technically this should be part of XDG_DATA_DIRS, but we add it in here)
// - Defaults to $HOME/.local/share
// - $XDG_DATA_DIRS/icons
// - Defaults to /usr/local/share/:/usr/share/
// - /usr/share/pixmaps
let mut directories = vec![];
let xdg_data_dirs = non_empty_var(ENV_DATA_DIRS)
.or_else(|| Some("/usr/local/share/:/usr/share/".to_string()));
if let Some(home) = dirs::home_dir() {
directories.push(home.join(".icons"));
}
if let Some(xdg_data_home) = dirs::data_dir() {
directories.push(xdg_data_home.join("icons"));
}
if let Some(xdg_data_dirs) = xdg_data_dirs {
for dir in xdg_data_dirs.split(':') {
if !dir.is_empty() {
directories.push(PathBuf::from(dir).join("icons"));
}
}
}
directories.push(PathBuf::from("/usr/share/pixmaps"));
Self { directories }
}
/// First checks to see if there is a default cursor theme set.
/// If there is no default set, we check a list of known themes.
/// The first theme to be confirmed exist is returned, else None
/// is returned.
fn determine_cursor_theme(&self) -> Option<String> {
if self.check_cursor_theme(DEFAULT_THEME) {
return Some(DEFAULT_THEME.to_string());
}
for theme in KNOWN_THEMES {
if self.check_cursor_theme(theme) {
return Some((*theme).to_string());
}
}
None
}
/// Returns true if an icon theme exists and has a `cursors/`
/// folder, indicating that the cursors for that theme are installed.
/// Per the specification, an icon theme can exist along multiple
/// directories. As long as at least one of those directories
/// contains the `cursors/` subdir, we consider it valid
fn check_cursor_theme_installed(&self, theme: &str) -> bool {
for dir in &self.directories {
if dir.join(theme).join(CURSOR_DIR_NAME).exists() {
return true;
}
}
false
}
/// Checks that a given icon theme is a valid cursor theme.
///
/// When we check a cursor theme, we verify that either:
/// a. The icon theme has a cursors/ folder
/// b. The icon theme inherits from an existing cursor theme.
///
/// This can cause us to traverse multiple themes as part of our validation.
/// we do this verification to handle cases like the `adwaita-icon-theme`
/// deb packages, which sets Adwaita to the default icon theme without
/// installing a cursor theme.
fn check_cursor_theme(&self, root_theme: &str) -> bool {
let mut visited = std::collections::HashSet::from([root_theme.to_string()]);
let mut pending = std::collections::VecDeque::from([root_theme.to_string()]);
while let Some(theme) = pending.pop_front() {
if self.check_cursor_theme_installed(&theme) {
return true;
}
// Per the spec, the **first** index.theme found when traversing
// the directories is used
let inherited_themes = &self
.directories
.iter()
.filter_map(|index_dir| {
let index_path = index_dir.join(&theme).join(CURSOR_INDEX_FILE_NAME);
if let Ok(theme_file) = Ini::from_file(&index_path) {
theme_file.get_vec_with_sep::<String>(
THEME_FILE_CURSOR_SECTION,
THEME_FILE_INHERITS_KEY,
",",
)
} else {
None
}
})
.next();
if let Some(inherited_themes) = inherited_themes {
for new_theme in inherited_themes {
if !visited.contains(new_theme) {
visited.insert(new_theme.clone());
pending.push_back(new_theme.clone());
}
}
}
}
false
}
}
#[cfg(test)]
#[path = "cursor_theme_tests.rs"]
mod tests;
@@ -0,0 +1,159 @@
use super::CursorThemeCrawler;
use ::virtual_fs::{Stub, VirtualFS};
#[test]
fn test_no_themes_found() {
VirtualFS::test("test_no_themes_found", |dirs, mut sandbox| {
sandbox.mkdir("icons");
let crawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons")],
};
assert_eq!(crawler.determine_cursor_theme(), None);
});
}
#[test]
fn test_default_theme_found() {
VirtualFS::test("test_default_theme_found", |dirs, mut sandbox| {
sandbox.mkdir("icons/default/cursors");
let crawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons")],
};
assert_eq!(
crawler.determine_cursor_theme(),
Some("default".to_string())
);
});
}
#[test]
fn test_known_theme_found() {
VirtualFS::test("test_known_theme_found", |dirs, mut sandbox| {
sandbox.mkdir("icons/Yaru/cursors");
let crawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons")],
};
assert_eq!(crawler.determine_cursor_theme(), Some("Yaru".to_string()));
});
}
#[test]
fn test_default_theme_found_via_index() {
VirtualFS::test("test_default_theme_found_via_index", |dirs, mut sandbox| {
sandbox.mkdir("icons/Darmok/cursors");
sandbox.mkdir("icons/default");
sandbox.with_files(vec![Stub::FileWithContent(
"icons/default/index.theme",
r#"
[Icon Theme]
Inherits=Darmok
"#,
)]);
let crawler: CursorThemeCrawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons")],
};
assert_eq!(
crawler.determine_cursor_theme(),
Some("default".to_string())
);
});
}
#[test]
fn test_default_theme_is_prioritized_over_known_theme() {
VirtualFS::test(
"test_default_theme_is_prioritized_over_known_theme",
|dirs, mut sandbox| {
sandbox.mkdir("icons/Darmok/cursors");
sandbox.mkdir("icons/Yaru/cursors");
sandbox.mkdir("icons/default");
sandbox.with_files(vec![Stub::FileWithContent(
"icons/default/index.theme",
r#"
[Icon Theme]
Inherits=Darmok
"#,
)]);
let crawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons")],
};
assert_eq!(
crawler.determine_cursor_theme(),
Some("default".to_string())
);
},
);
}
#[test]
fn test_multiple_directories() {
VirtualFS::test("test_multiple_directories", |dirs, mut sandbox| {
sandbox.mkdir("icons2/Darmok/cursors");
sandbox.mkdir("icons/default");
sandbox.with_files(vec![Stub::FileWithContent(
"icons/default/index.theme",
r#"
[Icon Theme]
Inherits=Darmok
"#,
)]);
let crawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons"), dirs.tests().join("icons2")],
};
assert_eq!(
crawler.determine_cursor_theme(),
Some("default".to_string())
);
});
}
#[test]
fn test_resolution_order() {
VirtualFS::test("test_resolution_order", |dirs, mut sandbox| {
sandbox.mkdir("icons2/Darmok/cursors");
sandbox.mkdir("icons/default");
sandbox.mkdir("icons2/default");
sandbox.with_files(vec![
Stub::FileWithContent(
"icons/default/index.theme",
r#"
[Icon Theme]
Inherits=Jalad
"#,
),
Stub::FileWithContent(
"icons2/default/index.theme",
r#"
[Icon Theme]
Inherits=Darmok
"#,
),
]);
// Case 1: we find the index file in icons first.
// The index file points to a non-existent theme Jalad,
// so we return None
let crawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons"), dirs.tests().join("icons2")],
};
assert_eq!(crawler.determine_cursor_theme(), None);
// Case 2: we find the index file in icons first.
// The index file points to the valid theme Darmok,
// so we return Some("default")
let crawler = CursorThemeCrawler {
directories: vec![dirs.tests().join("icons2"), dirs.tests().join("icons")],
};
assert_eq!(
crawler.determine_cursor_theme(),
Some("default".to_string())
);
});
}
@@ -0,0 +1,11 @@
mod app;
pub mod clipboard;
mod cursor_theme;
mod window_manager;
mod zbus;
pub use app::{maybe_register_xlib_error_hook, take_encountered_bad_match_from_dri3_fence_from_fd};
pub use clipboard::*;
pub use cursor_theme::*;
pub(crate) use window_manager::*;
pub use zbus::*;
@@ -0,0 +1,143 @@
use command::blocking::Command;
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::process::Stdio;
use std::{env, fs, path};
/// Attempt to find a running process that we believe is the window compositor.
///
/// The name comes from `/proc/$pid/comm`, and so it will be truncated to the first 15 chars of the
/// actual process name.
/// https://superuser.com/questions/567648/ps-comm-format-always-cuts-the-process-name
pub(crate) fn look_for_wayland_compositor() -> Option<String> {
// First, try to determine the compositor by looking at the Wayland display
// socket and seeing which process is listening on it.
//
// TODO(CORE-3034): Re-enable this codepath once we've understood and
// addressed the lsof performance issues.
// if let Some(compositor_name) = get_wayland_compositor_from_socket() {
// return Some(compositor_name);
// }
// If the above method didn't work, fallback to a less precise method. Simply use `ps
// -u` and grep for a recognized set of names among the running processes. This may
// have false positives, like processes that name-clash with these compositors.
let uid = nix::unistd::getuid();
let euid = nix::unistd::geteuid();
if let Some(ps_output) = Command::new("ps")
.args(["-u", &format!("{euid}"), "-U", &format!("{uid}")])
.stdout(Stdio::piped())
.spawn()
.ok()
.and_then(|output| output.stdout)
{
let wm_match_cmd = Command::new("grep")
.args(
["-m", "1", "-o", "-F", "-i"].iter().chain(
WAYLAND_TILING_WM
.iter()
.flat_map(|wm_name| [&"-e", wm_name]),
),
)
.stdin(Stdio::from(ps_output))
.output()
.ok()
.filter(|out| out.status.success());
if let Some(wm_name_raw) = wm_match_cmd {
if let Ok(wm_name) = String::from_utf8(wm_name_raw.stdout) {
if !wm_name.is_empty() {
return Some(wm_name);
}
}
}
}
None
}
/// Returns the name of the Wayland compositor by looking at the Wayland
/// display socket and seeing which process is listening on it, or [`None`] if
/// we were unable to compute it for any reason.
///
/// TODO(CORE-3034): Re-enable this codepath and remove the allow(dead_code)
/// attribute.
#[allow(dead_code)]
fn get_wayland_compositor_from_socket() -> Option<String> {
// https://discourse.ubuntu.com/t/environment-variables-for-wayland-hackers/12750
let xdg_runtime_dir = env::var("XDG_RUNTIME_DIR")
.ok()
.filter(|val| !val.is_empty())?;
let wayland_display = env::var("WAYLAND_DISPLAY")
.ok()
.filter(|val| !val.is_empty())
.unwrap_or("wayland-0".to_owned());
// Wayland compositors communicate with their clients using a UNIX socket. This path is the
// standard location of that socket.
let wayland_socket_path = path::Path::new(xdg_runtime_dir.as_str()).join(wayland_display);
let socket_metadata = fs::metadata(&wayland_socket_path).ok()?;
// Validate that this file is a socket owned by the effective user ID.
if !socket_metadata.file_type().is_socket()
|| socket_metadata.uid() != nix::unistd::geteuid().as_raw()
{
return None;
}
let path_str = wayland_socket_path.to_str()?;
// If we found a valid socket, try either `lsof` or `fuser` to identify the process
// which is listening at this socket. This is the most precise method of doing this,
// but not all Linux systems have these tools installed, and if they do they may still
// require elevated privileges.
let get_pid_cmd = Command::new("lsof")
.args(["-t", path_str])
.stderr(Stdio::null())
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| output.stdout)
.or_else(|| {
Command::new("fuser")
.arg(path_str)
.stderr(Stdio::null())
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| output.stdout)
});
// If the above method worked, lookup the name of that pid.
if let Some(raw_pid) = get_pid_cmd {
let pid = String::from_utf8(raw_pid).ok()?.trim().to_owned();
// Validate that an integer pid was returned.
pid.parse::<i32>().ok()?;
if let Ok(wm_name_raw) = Command::new("ps")
.args(["-p", pid.as_str(), "-o", "comm="])
.output()
{
if let Ok(wm_name) = String::from_utf8(wm_name_raw.stdout) {
return Some(wm_name);
}
}
}
None
}
/// Hand-picked tiling wayland compositors. These are the two most starred on GitHub.
const WAYLAND_TILING_WM: &[&str] = &["hyprland", "sway"];
pub(crate) fn is_tiling_window_manager(name: &str) -> bool {
// List of X11 tiling window managers copied from Chromium repo:
// https://source.chromium.org/chromium/chromium/src/+/6fa59a48:ui/base/x/x11_util.cc;l=374
const X11_TILING_WM: &[&str] = &["i3", "ion3", "notion", "ratpoison", "stumpwm"];
// Dynamic window managers can be configured to function as either tiling or stacking. It is
// impractical for us to introspect how these are configured, so for now we copy Chrome's
// approach to assume they are used as tiling.
const X11_DYNAMIC_WM: &[&str] = &["awesome", "qtile", "xmonad", "wmii"];
let normalized = name.trim().to_lowercase();
X11_TILING_WM.contains(&normalized.as_str())
|| X11_DYNAMIC_WM.contains(&normalized.as_str())
|| WAYLAND_TILING_WM.contains(&normalized.as_str())
}
@@ -0,0 +1,181 @@
//! Provides an application-agnostic D-Bus client for retrieving the
//! desktop environment's appearance settings.
use std::ops::Deref as _;
use std::time::Duration;
use futures::StreamExt as _;
use winit::event_loop::EventLoopProxy;
use zbus::{proxy, zvariant};
use crate::{
platform::SystemTheme,
r#async::{block_on, executor::Background, FutureExt as _},
windowing::winit::app::CustomEvent,
};
const COLOR_SCHEME_SETTINGS_NAMESPACE: &str = "org.freedesktop.appearance";
const COLOR_SCHEME_SETTINGS_KEY: &str = "color-scheme";
/// Values used by the desktop environment to encode the user's
/// system color scheme preference.
#[derive(Debug, Default, serde::Deserialize, serde::Serialize, zbus::zvariant::Type, PartialEq)]
enum SystemColorScheme {
#[default]
NoPreference = 0,
Dark = 1,
Light = 2,
}
impl From<&u32> for SystemColorScheme {
fn from(value: &u32) -> SystemColorScheme {
match value {
0 => SystemColorScheme::NoPreference,
1 => SystemColorScheme::Dark,
2 => SystemColorScheme::Light,
_ => SystemColorScheme::NoPreference,
}
}
}
impl From<&str> for SystemColorScheme {
fn from(value: &str) -> SystemColorScheme {
match value {
"prefer-dark" => SystemColorScheme::Dark,
"prefer-light" => SystemColorScheme::Light,
_ => SystemColorScheme::NoPreference,
}
}
}
impl From<&zvariant::Str<'_>> for SystemColorScheme {
fn from(value: &zvariant::Str) -> SystemColorScheme {
SystemColorScheme::from(value.as_str())
}
}
impl From<&zvariant::Value<'_>> for SystemColorScheme {
fn from(value: &zvariant::Value) -> SystemColorScheme {
match value {
zvariant::Value::U32(u) => SystemColorScheme::from(u),
zvariant::Value::Str(s) => SystemColorScheme::from(s),
zvariant::Value::Value(boxed_v) => match boxed_v.downcast_ref::<u32>() {
Ok(v) => SystemColorScheme::from(&v),
Err(err) => {
log::error!(
"D-Bus inner variant type {:#?}: {:#?} could not be converted to SystemThemePreference: {err:#}",
value.value_signature(),
value
);
SystemColorScheme::NoPreference
}
},
_ => {
log::error!(
"D-Bus outer variant type {:#?}: {:#?} could not be converted to SystemThemePreference",
value.value_signature(),
value
);
SystemColorScheme::NoPreference
}
}
}
}
impl From<&zvariant::OwnedValue> for SystemColorScheme {
fn from(owned_value: &zvariant::OwnedValue) -> SystemColorScheme {
SystemColorScheme::from(owned_value.deref())
}
}
impl From<SystemColorScheme> for SystemTheme {
fn from(os_value: SystemColorScheme) -> SystemTheme {
match os_value {
SystemColorScheme::Dark => SystemTheme::Dark,
SystemColorScheme::Light => SystemTheme::Light,
SystemColorScheme::NoPreference => SystemTheme::default(),
}
}
}
/// A D-Bus client for connecting to the desktop settings.
#[proxy(
interface = "org.freedesktop.portal.Settings",
default_service = "org.freedesktop.portal.Desktop",
default_path = "/org/freedesktop/portal/desktop"
)]
trait DesktopSettings {
fn read(&self, namespace: &str, key: &str) -> zbus::fdo::Result<zvariant::OwnedValue>;
#[zbus(signal)]
fn setting_changed(
&self,
interface_name: &str,
setting_name: &str,
new_setting_value: zvariant::Value<'_>,
) -> zbus::fdo::Result<()>;
}
/// Sets up a background task to listen to desktop settings change events sent
/// over dbus and inject events into the winit EventLoop accordingly.
pub fn watch_desktop_settings_changes(
event_proxy: EventLoopProxy<CustomEvent>,
background: &Background,
) {
background
.spawn(async move {
if let Err(err) = watch_desktop_settings_changes_internal(event_proxy).await {
log::warn!(
"Encountered error while watching for desktop settings change events: {err:#}"
);
}
})
.detach();
}
async fn watch_desktop_settings_changes_internal(
event_proxy: EventLoopProxy<CustomEvent>,
) -> zbus::Result<()> {
let connection = zbus::Connection::session().await?;
let desktop_settings_proxy = DesktopSettingsProxy::new(&connection).await?;
let mut stream = desktop_settings_proxy.receive_setting_changed().await?;
while let Some(msg) = stream.next().await {
let Ok(args) = msg.args() else {
log::warn!("appearance settings signal should have arguments");
continue;
};
// As of now, we are only interested in system color scheme changes.
// In the future, we may check for other types of signals.
if let (&COLOR_SCHEME_SETTINGS_NAMESPACE, &COLOR_SCHEME_SETTINGS_KEY) =
(args.interface_name(), args.setting_name())
{
let _ = event_proxy.send_event(CustomEvent::SystemThemeChanged);
}
}
Ok(())
}
/// Retrieves the system color scheme, blocking for up to 200ms to get the
/// value via dbus.
pub fn get_system_theme() -> Result<SystemTheme, zbus::Error> {
block_on(async {
query_system_theme_from_dbus()
.with_timeout(Duration::from_millis(200))
.await
.unwrap_or_else(|_| {
Err(zbus::Error::from(zbus::fdo::Error::TimedOut(
"Failed to get a response within 200ms".to_owned(),
)))
})
})
}
/// Queries the current D-Bus session bus to get the system color scheme.
async fn query_system_theme_from_dbus() -> Result<SystemTheme, zbus::Error> {
let client_conn = zbus::Connection::session().await?;
let settings_proxy = DesktopSettingsProxy::new(&client_conn).await?;
let owned_val = settings_proxy
.read(COLOR_SCHEME_SETTINGS_NAMESPACE, COLOR_SCHEME_SETTINGS_KEY)
.await?;
Ok(SystemColorScheme::from(&owned_val).into())
}
@@ -0,0 +1,7 @@
mod desktop_settings;
mod network_status;
mod suspend_resume;
pub use desktop_settings::*;
pub use network_status::watch_network_status_changed;
pub use suspend_resume::watch_suspend_resume_changes;
@@ -0,0 +1,53 @@
use futures_lite::StreamExt;
use winit::event_loop::EventLoopProxy;
use zbus::proxy;
use crate::{r#async::executor::Background, windowing::winit::app::CustomEvent};
/// A zbus proxy for receiving network status signals from `NetworkManager`.
#[proxy(
interface = "org.freedesktop.NetworkManager",
default_service = "org.freedesktop.NetworkManager",
default_path = "/org/freedesktop/NetworkManager",
gen_blocking = false
)]
trait NetworkManager {
#[zbus(signal)]
fn state_changed(&self, state: u32) -> zbus::Result<()>;
}
/// Sets up a background task to listen to changes to network status.
pub fn watch_network_status_changed(
event_proxy: EventLoopProxy<CustomEvent>,
background: &Background,
) {
background
.spawn(async move {
if let Err(err) = watch_network_status_changed_internal(event_proxy).await {
log::warn!("Encountered error while watching for network status events: {err:#}");
}
})
.detach();
}
async fn watch_network_status_changed_internal(
event_proxy: EventLoopProxy<CustomEvent>,
) -> zbus::Result<()> {
let connection = zbus::Connection::system().await?;
let network_manager_proxy = NetworkManagerProxy::new(&connection).await?;
let mut state_changed_stream = network_manager_proxy.receive_state_changed().await?;
while let Some(msg) = state_changed_stream.next().await {
if let Ok(args) = msg.args() {
// Only consider the internet as connected if it is equivalent to
// `NM_STATE_CONNECTED_GLOBAL`, indicating there is "full network connectivity". See
// https://developer-old.gnome.org/NetworkManager/stable/nm-dbus-types.html for more
// information.
if args.state == 70 {
let _ = event_proxy.send_event(CustomEvent::InternetConnected);
} else {
let _ = event_proxy.send_event(CustomEvent::InternetDisconnected);
}
}
}
Ok(())
}
@@ -0,0 +1,52 @@
use futures_lite::StreamExt;
use winit::event_loop::EventLoopProxy;
use zbus::proxy;
use crate::{r#async::executor::Background, windowing::winit::app::CustomEvent};
/// A zbus proxy for receiving PrepareForSleep signals from systemd-logind.
#[proxy(
interface = "org.freedesktop.login1.Manager",
default_service = "org.freedesktop.login1",
default_path = "/org/freedesktop/login1",
gen_blocking = false
)]
trait LoginManager {
#[zbus(signal)]
fn prepare_for_sleep(&self, start: bool) -> zbus::Result<()>;
}
/// Sets up a background task to listen to suspend/resume events sent over dbus
/// and inject events into the winit EventLoop accordingly.
pub fn watch_suspend_resume_changes(
event_proxy: EventLoopProxy<CustomEvent>,
background: &Background,
) {
background
.spawn(async move {
if let Err(err) = watch_suspend_resume_changes_internal(event_proxy).await {
log::warn!(
"Encountered error while watching for system suspend/resume events: {err:#}"
);
}
})
.detach();
}
async fn watch_suspend_resume_changes_internal(
event_proxy: EventLoopProxy<CustomEvent>,
) -> zbus::Result<()> {
let connection = zbus::Connection::system().await?;
let login_manager_proxy = LoginManagerProxy::new(&connection).await?;
let mut stream = login_manager_proxy.receive_prepare_for_sleep().await?;
while let Some(msg) = stream.next().await {
if let Ok(args) = msg.args() {
if args.start {
let _ = event_proxy.send_event(CustomEvent::AboutToSleep);
} else {
let _ = event_proxy.send_event(CustomEvent::ResumedFromSleep);
}
}
}
Ok(())
}
@@ -0,0 +1,23 @@
pub(crate) mod app;
pub mod delegate;
mod event_loop;
pub(crate) mod fonts;
#[cfg(target_os = "linux")]
pub mod linux;
mod notifications;
#[cfg(target_family = "wasm")]
pub mod wasm;
mod window;
#[cfg(target_os = "windows")]
pub mod windows;
use app::CustomEvent;
#[cfg(target_os = "linux")]
pub use app::WindowingSystem;
use event_loop::EventLoop;
#[cfg(target_os = "linux")]
pub use window::get_os_window_manager_name;
use window::Window;
@@ -0,0 +1,53 @@
use crate::notification::NotificationSendError;
use crate::windowing::winit::app::CustomEvent;
use crate::windowing::winit::notifications::NotificationInfo;
use crate::WindowId;
use futures::FutureExt;
use winit::event_loop::EventLoopProxy;
pub(super) async fn send_notification(
notification_info: NotificationInfo,
_window_id: WindowId,
proxy: EventLoopProxy<CustomEvent>,
) {
let NotificationInfo {
notification_content,
on_error,
} = notification_info;
let mut notification = notify_rust::Notification::new();
notification
.summary(notification_content.title())
.body(notification_content.body());
notification
.show_async()
.then(|handle| async move {
match handle {
Ok(handle) => {
// The call to on_close blocks until the notification is closed, so make the blocking
// call on its own thread in the `blocking` crate threadpool to avoid starving the shared
// background executor.
blocking::unblock(move || {
// Without the on_close handler, the notification will fail to appear.
handle.on_close(|reason| log::info!("Notification closed via {reason:?}"))
})
.await;
}
Err(err) => {
// Always consider the error to be a `NotificationSendError::Other`.
// Dbus does not report if a notification couldn't be shown because
// the application didn't have permissions, so we can never return a
// `NotificationSendError::PermissionDenied` error.
let error = NotificationSendError::Other {
error_message: err.to_string(),
};
let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
on_error(error, ctx);
})));
}
}
})
.await
}
@@ -0,0 +1,49 @@
//! Module to display system desktop notifications through the winit windowing backend.
use crate::platform::NotificationInfo;
use crate::platform::{RequestNotificationPermissionsCallback, SendNotificationErrorCallback};
use crate::windowing::winit::app::CustomEvent;
use crate::{notification, WindowId};
use winit::event_loop::EventLoopProxy;
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_family = "wasm", path = "wasm.rs")]
mod imp;
#[cfg(target_family = "wasm")]
pub(super) use imp::request_notification_permissions;
pub async fn send_notification(
notification_info: NotificationInfo,
window_id: WindowId,
event_loop_proxy: EventLoopProxy<CustomEvent>,
) {
imp::send_notification(notification_info, window_id, event_loop_proxy).await
}
pub(super) fn request_desktop_notification_permissions(
on_completion: RequestNotificationPermissionsCallback,
event_loop_proxy: &EventLoopProxy<CustomEvent>,
) {
let _ = event_loop_proxy.send_event(CustomEvent::RequestNotificationPermissions(Box::new(
|outcome, ctx| on_completion(outcome, ctx),
)));
}
pub(super) fn send_desktop_notification(
notification_content: notification::UserNotification,
window_id: WindowId,
on_error: SendNotificationErrorCallback,
event_loop_proxy: &EventLoopProxy<CustomEvent>,
) {
use crate::platform::NotificationInfo;
let _ = event_loop_proxy.send_event(CustomEvent::SendNotification {
window_id,
notification_info: NotificationInfo {
notification_content,
on_error,
},
});
}
@@ -0,0 +1,90 @@
use crate::notification::NotificationSendError;
use crate::notification::RequestPermissionsOutcome;
use crate::platform::NotificationInfo;
use crate::windowing::winit::app::RequestPermissionsCallback;
use crate::windowing::winit::CustomEvent;
use crate::WindowId;
use wasm_bindgen_futures::JsFuture;
use winit::event_loop::EventLoopProxy;
pub async fn send_notification(
notification_info: NotificationInfo,
_window_id: WindowId,
proxy: EventLoopProxy<CustomEvent>,
) {
let NotificationInfo {
notification_content,
on_error,
} = notification_info;
// First, we check to see if the page has permissions to send notifications. If not, we should prematurely
// execute the on_error callback. We can't rely on the result of the web_sys::Notification constructor to
// know whether the notification has the right permissions to actually send.
// https://developer.mozilla.org/en-US/docs/Web/API/Notification/Notification#return_value.
match web_sys::Notification::permission() {
web_sys::NotificationPermission::Granted => {
// If permissions are granted, send it! Constructing the Notification object is enough to launch it.
let _ = web_sys::Notification::new(notification_content.title());
}
web_sys::NotificationPermission::Default => {
let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
on_error(NotificationSendError::PermissionsNotYetGranted, ctx)
})));
}
web_sys::NotificationPermission::Denied => {
let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
on_error(NotificationSendError::PermissionsDenied, ctx)
})));
}
_ => {
let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
on_error(
NotificationSendError::Other {
error_message: "unknown notifications permissions".to_string(),
},
ctx,
)
})));
}
}
}
pub async fn request_notification_permissions(
callback: RequestPermissionsCallback,
proxy: EventLoopProxy<CustomEvent>,
) {
// The web_sys request_permission method returns a Promise that resolves to a string indicating
// whether the permissions request was granted, denied, or default.
// See https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission_static.
let Ok(permissions_request_promise) = web_sys::Notification::request_permission() else {
let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
callback(
RequestPermissionsOutcome::OtherError {
error_message: "Error sending notification permissions request".to_string(),
},
ctx,
);
})));
return;
};
let request_outcome = match JsFuture::from(permissions_request_promise)
.await
.map(|r| r.as_string())
{
Ok(Some(user_response)) if user_response == "granted" => {
RequestPermissionsOutcome::Accepted
}
// Any response besides "granted" is considered a permissions denied.
Ok(Some(_)) => RequestPermissionsOutcome::PermissionsDenied,
_ => RequestPermissionsOutcome::OtherError {
error_message: "Error receiving response from notification permissions request"
.to_string(),
},
};
// When the request has completed, we execute the callback with the outcome.
let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
callback(request_outcome, ctx);
})));
}
@@ -0,0 +1,52 @@
use crate::notification::NotificationSendError;
use crate::windowing::winit::{app::CustomEvent, notifications::NotificationInfo};
use crate::WindowId;
use tauri_winrt_notification::Toast;
use winit::event_loop::EventLoopProxy;
pub(super) async fn send_notification(
notification_info: NotificationInfo,
window_id: WindowId,
proxy: EventLoopProxy<CustomEvent>,
) {
let NotificationInfo {
notification_content,
on_error,
} = notification_info;
let powershell_app_id = Toast::POWERSHELL_APP_ID.to_string();
let app_id = unsafe { fetch_windows_app_id() }
.ok()
.unwrap_or(powershell_app_id);
let proxy_clone = proxy.clone();
let toast = Toast::new(&app_id)
.title(notification_content.title())
.text1(notification_content.body())
.on_activated(move |_activated_arguments| {
let _ = proxy_clone
.send_event(CustomEvent::FocusWindow { window_id })
.map_err(|err| {
log::warn!("Unable to focus window after event loop closed: {err:?}");
});
Ok(())
});
if let Err(err) = toast.show() {
let error = NotificationSendError::Other {
error_message: err.to_string(),
};
let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
on_error(error, ctx);
})));
}
}
unsafe fn fetch_windows_app_id() -> Result<String, anyhow::Error> {
let app_id_pwstr = windows::Win32::UI::Shell::GetCurrentProcessExplicitAppUserModelID()
.map_err(|win_err| {
log::warn!("error retrieving Win32 AppUserModel ID: {win_err:?}");
anyhow::anyhow!(win_err)
})?;
Ok(app_id_pwstr.to_string()?)
}
@@ -0,0 +1,594 @@
use super::*;
use crate::fonts::{collect_glyph_indices, init_fonts, Properties};
use crate::platform::FontDB as _;
use crate::{
elements::DEFAULT_UI_LINE_HEIGHT_RATIO,
text_layout::{TextStyle, DEFAULT_TOP_BOTTOM_RATIO},
};
use anyhow::Result;
const FONT_SIZE: f32 = 16.;
const FRAME_WIDTH: f32 = 80.;
const FRAME_HEIGHT: f32 = f32::MAX;
#[test]
fn test_fixed_width_tab_size_affects_tab_width() -> Result<()> {
let (font_db, roboto) = init_fonts();
let tabbed = "\tX";
let spaced = " X";
let line_style = LineStyle {
font_size: FONT_SIZE,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: Some(8),
};
let tabbed_line = font_db.text_layout_system().layout_line(
tabbed,
line_style,
&[(
0..tabbed.chars().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)],
f32::MAX,
crate::text_layout::ClipConfig::default(),
);
let spaced_line = font_db.text_layout_system().layout_line(
spaced,
line_style,
&[(
0..spaced.chars().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)],
f32::MAX,
crate::text_layout::ClipConfig::default(),
);
let error = (tabbed_line.width - spaced_line.width).abs();
assert!(
error < 1.0,
"expected tab width ~= 8 spaces; got tabbed {}, spaced {} (error {})",
tabbed_line.width,
spaced_line.width,
error
);
Ok(())
}
#[test]
fn test_layout_text_first_line_indent_small() -> Result<()> {
let (font_db, roboto) = init_fonts();
let text = "Let's lay out s𐍈me Roboto text.";
// 0123456789012345678901234567890
let line_style = LineStyle {
font_size: FONT_SIZE,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
};
let style_runs = [(
0..text.encode_utf16().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)];
// First, lay out the text with no head indent.
let no_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
None,
);
// The text should contain multiple lines.
// The first line has about the same amount of content as the others,
// since there's no head indent.
assert_eq!(no_indent_frame.lines().len(), 4);
assert_eq!(
collect_glyph_indices(&no_indent_frame),
vec![
vec![0, 1, 2, 3, 4, 5, 6, 7, 8], // 9 is whitespace.
vec![10, 11, 12, 13, 14, 15, 16, 17], // 18 is whitespace.
vec![19, 20, 21, 22, 23, 24], // 25 is whitespace.
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(&no_indent_frame, 0., FRAME_WIDTH));
assert!(all_lines_bounded(&no_indent_frame, FRAME_WIDTH));
// Lay out the text with a 5px head indent.
let small_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(5.),
);
// The first line has about the same amount of content as the others,
// since the head indent is small.
assert_eq!(small_indent_frame.lines().len(), 4);
assert_eq!(
collect_glyph_indices(&small_indent_frame),
vec![
vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
vec![10, 11, 12, 13, 14, 15, 16, 17],
vec![19, 20, 21, 22, 23, 24],
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(&small_indent_frame, 5., FRAME_WIDTH));
assert!(all_lines_bounded(&small_indent_frame, FRAME_WIDTH));
// Lay out the text with a 40px head indent,
// which is half the width of the frame.
let half_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH / 2.),
);
// The text contains an additional line to accommodate the indent.
assert_eq!(half_indent_frame.lines().len(), 5);
assert_eq!(
collect_glyph_indices(&half_indent_frame),
vec![
vec![0, 1, 2, 3, 4], // Fewer glyphs fit on this line. 5 is whitespace.
vec![6, 7, 8, 9, 10, 11, 12], // 13 is whitespace.
vec![14, 15, 16, 17],
vec![19, 20, 21, 22, 23, 24],
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(
&half_indent_frame,
FRAME_WIDTH / 2.,
FRAME_WIDTH,
));
assert!(all_lines_bounded(&half_indent_frame, FRAME_WIDTH));
Ok(())
}
#[test]
fn test_layout_text_first_line_indent_medium() -> Result<()> {
let (font_db, roboto) = init_fonts();
let text = "Let's lay out s𐍈me Roboto text.";
// 0123456789012345678901234567890
let line_style = LineStyle {
font_size: FONT_SIZE,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
};
let style_runs = [(
0..text.encode_utf16().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)];
// First, lay out the text with no head indent.
let no_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(0.),
);
// The text should contain multiple lines.
// The first line has about the same amount of content as the others,
// since there's no head indent.
assert_eq!(no_indent_frame.lines().len(), 4);
assert_eq!(
collect_glyph_indices(&no_indent_frame),
vec![
vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
vec![10, 11, 12, 13, 14, 15, 16, 17],
vec![19, 20, 21, 22, 23, 24],
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(&no_indent_frame, 0., FRAME_WIDTH));
assert!(all_lines_bounded(&no_indent_frame, FRAME_WIDTH));
// Lay out the text with a head indent that's 15px smaller than
// the width of the frame.
let overflow_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH - 20.),
);
// The first line should have some glyphs on it, but not the whole
// first word.
assert_eq!(overflow_indent_frame.lines().len(), 5);
assert_eq!(
collect_glyph_indices(&overflow_indent_frame),
vec![
vec![0, 1], // Only a few glyphs fit.
vec![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
vec![14, 15, 16, 17],
vec![19, 20, 21, 22, 23, 24],
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(
&overflow_indent_frame,
FRAME_WIDTH - 20.,
FRAME_WIDTH,
));
assert!(all_lines_bounded(&overflow_indent_frame, FRAME_WIDTH));
Ok(())
}
#[test]
fn test_layout_text_first_line_indent_large() -> Result<()> {
let (font_db, roboto) = init_fonts();
let text = "Let's lay out s𐍈me Roboto text.";
// 0123456789012345678901234567890
let line_style = LineStyle {
font_size: FONT_SIZE,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
};
let style_runs = [(
0..text.encode_utf16().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)];
// First, lay out the text with no head indent.
let no_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(0.),
);
// The text should contain multiple lines.
// The first line has about the same amount of content as the others,
// since there's no head indent.
assert_eq!(no_indent_frame.lines().len(), 4);
assert_eq!(
collect_glyph_indices(&no_indent_frame),
vec![
vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
vec![10, 11, 12, 13, 14, 15, 16, 17],
vec![19, 20, 21, 22, 23, 24],
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(&no_indent_frame, 0., FRAME_WIDTH));
assert!(all_lines_bounded(&no_indent_frame, FRAME_WIDTH));
// Lay out the text with a head indent that's 5px bigger than the width of the frame.
let overflow_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH + 5.),
);
// The first line is left entirely blank since no glyphs fit on it.
assert_eq!(
collect_glyph_indices(&overflow_indent_frame),
vec![
vec![], // No glyphs fit on this line.
vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
vec![10, 11, 12, 13, 14, 15, 16, 17],
vec![19, 20, 21, 22, 23, 24],
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(
&overflow_indent_frame,
FRAME_WIDTH + 5.,
FRAME_WIDTH,
));
assert!(all_lines_bounded(&overflow_indent_frame, FRAME_WIDTH));
// Lay out the text with a 79px head indent,
// which spans almost the entire width of the frame.
let big_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH - 0.1),
);
// The first line is left entirely blank since no glyphs fit on it.
assert_eq!(big_indent_frame.lines().len(), 5);
assert_eq!(
collect_glyph_indices(&big_indent_frame),
vec![
vec![], // No glyphs fit on this line.
vec![0, 1, 2, 3, 4, 5, 6, 7, 8],
vec![10, 11, 12, 13, 14, 15, 16, 17],
vec![19, 20, 21, 22, 23, 24],
vec![26, 27, 28, 29, 30],
]
);
assert!(first_line_bounded(
&big_indent_frame,
FRAME_WIDTH - 0.1,
FRAME_WIDTH,
));
assert!(all_lines_bounded(&big_indent_frame, FRAME_WIDTH));
Ok(())
}
// TODO(PLAT-779): check all line bounds once bidirectional wrapping is fixed in cosmic-text.
// See https://github.com/pop-os/cosmic-text/issues/252.
#[test]
fn test_layout_text_first_line_indent_small_bidirectional() -> Result<()> {
let (font_db, roboto) = init_fonts();
let text = "brekkie, إفطار, lunch (غداء) and dinner - عشاء";
// 0123456783210945678901265437890123456789015432
// RTL spans: |-----| |----| |----|
let line_style = LineStyle {
font_size: FONT_SIZE,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
};
let style_runs = [(
0..text.encode_utf16().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)];
// First, lay out the text with no head indent.
let no_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
None,
);
// The text should contain multiple lines.
// The first line has about the same amount of content as the others,
// since there's no head indent.
assert_eq!(no_indent_frame.lines().len(), 4);
assert!(first_line_bounded(&no_indent_frame, 0., FRAME_WIDTH));
// assert!(all_lines_bounded(&no_indent_frame, FRAME_WIDTH));
// Lay out the text with a 5px head indent.
let small_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(5.),
);
// The first line has about the same amount of content as the others,
// since the head indent is small.
assert_eq!(small_indent_frame.lines().len(), 4);
assert!(first_line_bounded(&small_indent_frame, 5., FRAME_WIDTH));
// assert!(all_lines_bounded(&small_indent_frame, FRAME_WIDTH));
// Lay out the text with a 40px head indent,
// which is half the width of the frame.
let half_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH / 2.),
);
// The text contains an additional line to accommodate the indent.
assert_eq!(half_indent_frame.lines().len(), 5);
assert!(first_line_bounded(
&half_indent_frame,
FRAME_WIDTH / 2.,
FRAME_WIDTH,
));
// assert!(all_lines_bounded(&half_indent_frame, FRAME_WIDTH));
Ok(())
}
// TODO(PLAT-779): check all line bounds once bidirectional wrapping is fixed in cosmic-text.
// See https://github.com/pop-os/cosmic-text/issues/252.
#[test]
fn test_layout_text_first_line_indent_medium_bidirectional() -> Result<()> {
let (font_db, roboto) = init_fonts();
let text = "brekkie, إفطار, lunch (غداء) and dinner - عشاء";
// 0123456783210945678901265437890123456789015432
// RTL spans: |-----| |----| |----|
let line_style = LineStyle {
font_size: FONT_SIZE,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
};
let style_runs = [(
0..text.encode_utf16().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)];
// First, lay out the text with no head indent.
let no_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
None,
);
// The text should contain multiple lines.
// The first line has about the same amount of content as the others,
// since there's no head indent.
assert_eq!(no_indent_frame.lines().len(), 4);
assert!(first_line_bounded(&no_indent_frame, 0., FRAME_WIDTH));
// assert!(all_lines_bounded(&no_indent_frame, FRAME_WIDTH));
// Lay out the text with a head indent that's 15px smaller than
// the width of the frame.
let overflow_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH - 20.),
);
// The first line should have some glyphs on it, but not the whole
// first word.
assert_eq!(overflow_indent_frame.lines().len(), 5);
assert!(first_line_bounded(
&overflow_indent_frame,
FRAME_WIDTH - 20.,
FRAME_WIDTH,
));
// assert!(all_lines_bounded(&overflow_indent_frame, FRAME_WIDTH));
Ok(())
}
// TODO(PLAT-779): check all line bounds once bidirectional wrapping is fixed in cosmic-text.
// See https://github.com/pop-os/cosmic-text/issues/252.
#[test]
fn test_layout_text_first_line_indent_large_bidirectional() -> Result<()> {
let (font_db, roboto) = init_fonts();
let text = "brekkie, إفطار, lunch (غداء) and dinner - عشاء";
// 0123456783210945678901265437890123456789015432
// RTL spans: |-----| |----| |----|
let line_style = LineStyle {
font_size: FONT_SIZE,
line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
fixed_width_tab_size: None,
};
let style_runs = [(
0..text.encode_utf16().count(),
StyleAndFont::new(roboto, Properties::default(), TextStyle::new()),
)];
// First, lay out the text with no head indent.
let no_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(0.),
);
// The text should contain multiple lines.
// The first line has about the same amount of content as the others,
// since there's no head indent.
assert_eq!(no_indent_frame.lines().len(), 4);
assert!(first_line_bounded(&no_indent_frame, 0., FRAME_WIDTH));
// assert!(all_lines_bounded(&no_indent_frame, FRAME_WIDTH));
// Lay out the text with a head indent that's 5px bigger than the width of the frame.
let overflow_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH + 5.),
);
// The first line is left entirely blank since no glyphs fit on it.
assert_eq!(overflow_indent_frame.lines().len(), 5);
assert!(collect_glyph_indices(&overflow_indent_frame)
.first()
.unwrap()
.is_empty(),);
assert!(first_line_bounded(
&overflow_indent_frame,
FRAME_WIDTH + 5.,
FRAME_WIDTH,
));
// assert!(all_lines_bounded(&overflow_indent_frame, FRAME_WIDTH));
// Lay out the text with a 79px head indent,
// which spans almost the entire width of the frame.
let big_indent_frame = font_db.text_layout_system().layout_text(
text,
line_style,
&style_runs,
FRAME_WIDTH,
FRAME_HEIGHT,
Default::default(),
Some(FRAME_WIDTH - 0.1),
);
// The first line is left entirely blank since no glyphs fit on it.
assert_eq!(big_indent_frame.lines().len(), 5);
assert!(collect_glyph_indices(&big_indent_frame)
.first()
.unwrap()
.is_empty(),);
assert!(first_line_bounded(
&big_indent_frame,
FRAME_WIDTH - 0.1,
FRAME_WIDTH,
));
// assert!(all_lines_bounded(&big_indent_frame, FRAME_WIDTH));
Ok(())
}
/// Checks that the head indent and first line's width don't exceed the frame's width.
fn first_line_bounded(frame: &TextFrame, first_line_indent: f32, frame_width: f32) -> bool {
let first_line_width = frame.lines().first().unwrap().width;
first_line_width + first_line_indent.min(frame_width) <= frame_width
}
fn all_lines_bounded(frame: &TextFrame, frame_width: f32) -> bool {
frame.lines().iter().fold(true, |all_bounded, line| {
let current_bounded = line.width <= frame_width;
all_bounded && current_bounded
})
}
@@ -0,0 +1,97 @@
use crate::{clipboard::ClipboardContent, Clipboard};
use js_sys::{Array, Object};
use wasm_bindgen::{self, prelude::*, JsCast};
use web_sys::{Blob, BlobPropertyBag};
pub struct WebClipboard {
inner: web_sys::Clipboard,
saved_content: ClipboardContent,
}
impl WebClipboard {
pub fn new() -> Self {
Self {
inner: gloo::utils::window().navigator().clipboard(),
saved_content: Default::default(),
}
}
}
impl Default for WebClipboard {
fn default() -> Self {
Self::new()
}
}
impl Clipboard for WebClipboard {
fn write(&mut self, contents: ClipboardContent) {
match create_item_list(&contents) {
Ok(item_list) => {
// This returns a Promise, which succeeds iff the copy succeeds. There's nothing we can do
// if the copy fails, though, and this API doesn't support async, so we just ignore the
// promise. It's not necessary to hold a reference to the promise for the copy to succeed.
let _ = self.inner.write(&item_list);
}
Err(error) => {
// Fall back to just writing plain text.
// ClipboardItems are not supported in Firefox yet.
log::warn!("Failed to construct clipboard data: {error:?}");
let _ = self.inner.write_text(&contents.plain_text);
}
}
}
fn read(&mut self) -> ClipboardContent {
std::mem::take(&mut self.saved_content)
}
fn save(&mut self, content: ClipboardContent) {
self.saved_content = content;
}
}
fn create_item_list(contents: &ClipboardContent) -> Result<Array, JsValue> {
// The Clipboard.write method
// (https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/write)
// requires an array of ClipboardItem objects
// (https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem).
// This function constructs a single element array containing a ClipboardItem, which contains
// both plain text and html data that's being copied.
let items = Object::new();
// We always have plain text data.
let text_blob = create_blob(&contents.plain_text, "text/plain")?;
js_sys::Reflect::set(&items, &JsValue::from_str("text/plain"), &text_blob)?;
// We sometimes have html data.
if let Some(html) = &contents.html {
let html_blob = create_blob(html, "text/html")?;
js_sys::Reflect::set(&items, &JsValue::from_str("text/html"), &html_blob)?;
}
// web_sys doesn't have this constructor, so we have to do things the hard way.
let clipboard_item_constructor: js_sys::Function = js_sys::Reflect::get(
&JsValue::from(gloo::utils::window()),
&JsValue::from_str("ClipboardItem"),
)?
.dyn_into()?;
let clipboard_item =
js_sys::Reflect::construct(&clipboard_item_constructor, &Array::of1(&items))?;
// Write the ClipboardItem to the clipboard
let item_list = Array::new();
item_list.push(&clipboard_item);
Ok(item_list)
}
fn create_blob(contents: &str, type_: &str) -> Result<Blob, JsValue> {
// See the JS Blob constructor docs for more info:
// https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob
let blob_parts = Array::new();
blob_parts.push(&JsValue::from_str(contents));
let blob_opts = BlobPropertyBag::new();
blob_opts.set_type(type_);
Blob::new_with_str_sequence_and_options(&blob_parts, &blob_opts)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
use crate::platform::WindowManager as _;
use crate::windowing::winit::window::WindowManager;
use crate::{DisplayId, DisplayIdx};
use anyhow::Result;
use itertools::Itertools as _;
use pathfinder_geometry::rect::RectF;
use std::sync::Arc;
use winit::monitor::MonitorHandle;
use winit::platform::windows::MonitorHandleExtWindows;
use winit::window::Window as WinitWindow;
use super::get_monitor_logical_bounds;
impl WindowManager {
fn get_active_window_handle(&self) -> Result<Arc<WinitWindow>> {
let window_id = &self
.active_window_id()
.ok_or(anyhow::anyhow!("No active window ID"))?;
let ui_window = self
.windows
.get(window_id)
.ok_or(anyhow::anyhow!("Window not found"))?;
let winit_window_borrow = ui_window.inner.try_borrow()?;
let winit_window_ref = winit_window_borrow
.as_ref()
.ok_or(anyhow::anyhow!("Unable to read Window information"))?;
Ok(winit_window_ref.window.clone())
}
fn get_current_monitor_handle(&self) -> Result<MonitorHandle> {
let winit_window_ref = self.get_active_window_handle()?;
winit_window_ref
.current_monitor()
.ok_or(anyhow::anyhow!("Unable to get current monitor"))
}
pub(super) fn get_monitor_bounds_for_display_idx(&self, idx: DisplayIdx) -> Result<RectF> {
let primary_monitor = self.get_primary_monitor_handle()?;
let monitor = match idx {
DisplayIdx::Primary => primary_monitor,
DisplayIdx::External(numerical_index) => {
let monitors = self.get_available_monitors()?;
monitors
.iter()
.filter(|monitor| {
// Filter out the primary monitor.
monitor.hmonitor() != primary_monitor.hmonitor()
})
.nth(numerical_index)
.ok_or(anyhow::anyhow!(
"Could not find monitor handle for {numerical_index:?}"
))?
.to_owned()
}
};
Ok(get_monitor_logical_bounds(&monitor))
}
fn get_primary_monitor_handle(&self) -> Result<MonitorHandle> {
let winit_window_ref = self.get_active_window_handle()?;
winit_window_ref
.primary_monitor()
.ok_or(anyhow::anyhow!("No primary monitor found"))
}
pub(super) fn get_current_monitor_id(&self) -> Result<DisplayId> {
let active_monitor = self.get_current_monitor_handle()?;
let active_monitor_id = active_monitor.hmonitor();
Ok(DisplayId::from(active_monitor_id as usize))
}
fn get_available_monitors(&self) -> Result<Vec<MonitorHandle>> {
let winit_window_ref = self.get_active_window_handle()?;
Ok(winit_window_ref.available_monitors().collect_vec())
}
pub(super) fn get_available_monitor_count(&self) -> Result<usize> {
let winit_window_ref = self.get_active_window_handle()?;
Ok(winit_window_ref.available_monitors().count())
}
pub(super) fn get_active_monitor_logical_bounds(&self) -> Result<RectF> {
let active_monitor = self.get_current_monitor_handle()?;
Ok(get_monitor_logical_bounds(&active_monitor))
}
}
@@ -0,0 +1,242 @@
use anyhow::anyhow;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::vec2f;
use winit::dpi::{PhysicalPosition, PhysicalSize};
use x11rb::connection::Connection;
use x11rb::protocol::randr::{self, MonitorInfo};
use x11rb::protocol::xproto::{self, AtomEnum, ConnectionExt};
use x11rb::rust_connection::RustConnection;
pub(super) type PhysicalMonitorBounds = (PhysicalPosition<i16>, PhysicalSize<u16>);
/// Holds a mapping of field names to "atoms" in X11.
///
/// In X11, "atoms" are basically enums. They are integers that map to strings, primarily to save
/// network bandwidth (X11 does not assume the GUI and the server are running on the same host).
struct Atoms {
/// For specifying the `UTF8_STRING` type. Confusingly, this is different from
/// [`AtomEnum::STRING`].
utf8_string: u32,
/// For the `_NET_ACTIVE_WINDOW` property on the root window.
net_active_window: u32,
/// For targeting `_NET_SUPPORTING_WM_CHECK` window.
net_supporting_wm_check: u32,
/// For the `_NET_WM_NAME` property.
net_wm_name: u32,
}
/// An X11 client so that we can talk to an Xorg server for more advanced functionality from a
/// desktop environment.
pub(super) struct X11Manager {
conn: RustConnection,
/// The index among a list of available screens which we are displaying on.
///
/// A "screen" in X11 parlance is not the concept of a monitor as we typically consider it.
/// Rather, if there are multiple monitors plugged in, they get pooled into a single, shared
/// coordinate space called a "screen". This allows windows to span multiple displays, as X11
/// does not assume that any window belongs to one monitor.
/// https://docs.google.com/drawings/d/1XeYRd9I7liQMj9w17QQZoeHSNYBJ_U0wEQh-pS_4eKM
screen_index: usize,
atoms: Atoms,
}
impl X11Manager {
pub(super) fn new() -> anyhow::Result<Self> {
let (conn, screen_index) = RustConnection::connect(None)?;
let utf8_string = conn.intern_atom(true, b"UTF8_STRING")?.reply()?.atom;
let net_active_window = conn
.intern_atom(false, b"_NET_ACTIVE_WINDOW")?
.reply()?
.atom;
let net_supporting_wm_check = conn
.intern_atom(true, b"_NET_SUPPORTING_WM_CHECK")?
.reply()?
.atom;
let net_wm_name = conn.intern_atom(true, b"_NET_WM_NAME")?.reply()?.atom;
Ok(Self {
conn,
screen_index,
atoms: Atoms {
net_active_window,
net_supporting_wm_check,
net_wm_name,
utf8_string,
},
})
}
/// Determines the index among a list of monitors for the "active" monitor. It also returns
/// metadata for that active monitor.
///
/// "Active" here means the monitor which the focused window is on. This may not be a window of
/// your application, but another app's window. Note that windows may span multiple monitors.
/// In that case, we pick the monitor which has the most overlap with the focused window.
pub(super) fn get_active_monitor(&self) -> anyhow::Result<(usize, PhysicalMonitorBounds)> {
// This logic is ported from `xdotool`
// https://github.com/jordansissel/xdotool/blob/7e02cef5d9216bd0ce69b44f62217b587cc7c31e/xdo.c#L208
let active_window_id = self.get_active_window()?;
// This determines if the active window is the child of another window, or a child of the
// "root". Indeed, windows in X11 are hierarchical.
let tree_reply = xproto::query_tree(&self.conn, active_window_id)?.reply()?;
// The meaning of "get_geometry" depends on this window's position in the hierarchy. This
// call gives us the "true" position only if the window is a child of the "root". If not,
// it gives us an offset position from its parent window.
// https://tronche.com/gui/x/xlib/window-information/XGetGeometry.html
let active_window_geometry = xproto::get_geometry(&self.conn, active_window_id)?.reply()?;
// If this window is a child of the "root", return the reported position.
let absolute_window_origin = if tree_reply.parent == tree_reply.root {
vec2f(
active_window_geometry.x as f32,
active_window_geometry.y as f32,
)
} else {
// Otherwise, "flatten" or "translate" the coordinates to be relative to the root.
// https://tronche.com/gui/x/xlib/window-information/XTranslateCoordinates.html
let translate_reply =
xproto::translate_coordinates(&self.conn, active_window_id, tree_reply.root, 0, 0)?
.reply()?;
vec2f(translate_reply.dst_x as f32, translate_reply.dst_y as f32)
};
let active_window_bounds = RectF::new(
absolute_window_origin,
vec2f(
active_window_geometry.width as f32,
active_window_geometry.height as f32,
),
);
// Get the full list of monitors and calculate which one overlaps with the active window
// the most.
let monitors = self.get_monitors(active_window_id)?;
let (i, monitor_bounds) = monitors
.iter()
.map(monitor_info_to_physical_bounds)
.enumerate()
.max_by(|(_, bounds_a), (_, bounds_b)| {
let intersection_a = active_window_bounds
.intersection(physical_bounds_to_rect(bounds_a, 1.))
.unwrap_or_default();
let intersection_b = active_window_bounds
.intersection(physical_bounds_to_rect(bounds_b, 1.))
.unwrap_or_default();
rect_area(intersection_a).total_cmp(&rect_area(intersection_b))
})
.ok_or(anyhow!(
"active window position doesn't fall on any windows"
))?;
Ok((i, monitor_bounds))
}
pub(super) fn list_monitor_bounds(&self) -> anyhow::Result<Box<[PhysicalMonitorBounds]>> {
let active_window_id = self.get_active_window()?;
let mut monitors = self.get_monitors(active_window_id)?;
// Ensure the primary display is first. This is not
monitors.sort_by(|a, b| b.primary.cmp(&a.primary));
Ok(monitors
.iter()
.map(monitor_info_to_physical_bounds)
.collect())
}
fn get_monitors(&self, window: xproto::Window) -> anyhow::Result<Vec<MonitorInfo>> {
// For most X11 calls, we reuse `self.conn` for the request. However, the response for
// `get_monitors` gets cached for the client. Subsequest calls just read the cached value,
// which doesn't seem to ever get invalidated. To ensure we read a fresh value, we
// construct a fresh connection client for every request.
let (conn, _) = RustConnection::connect(None)?;
let monitors = randr::get_monitors(&conn, window, false)?.reply()?.monitors;
Ok(monitors)
}
pub(super) fn os_window_manager_name(&self) -> anyhow::Result<String> {
let wm_check = xproto::get_property(
&self.conn,
false,
self.screen().root,
self.atoms.net_supporting_wm_check,
AtomEnum::WINDOW,
0,
1024,
)?
.reply()?
.value32()
.ok_or(anyhow!(
"Error getting _NET_SUPPORTING_WM_CHECK. Invalid response format."
))?
// X protocol responses are always iterators, even if the response is a single value.
.next()
.ok_or(anyhow!(
"Error getting _NET_SUPPORTING_WM_CHECK. Received empty response."
))?;
let wm_name_prop = xproto::get_property(
&self.conn,
false,
wm_check,
self.atoms.net_wm_name,
self.atoms.utf8_string,
0,
1024,
)?
.reply()?;
let wm_name = String::from_utf8(wm_name_prop.value)?;
Ok(wm_name)
}
fn screen(&self) -> &xproto::Screen {
&self.conn.setup().roots[self.screen_index]
}
/// Returns X11's window ID for the active window.
///
/// The "active" window is the one which has keyboard focus.
fn get_active_window(&self) -> anyhow::Result<xproto::Window> {
let active_window_reply = xproto::get_property(
&self.conn,
false,
self.screen().root,
self.atoms.net_active_window,
AtomEnum::WINDOW,
0,
1024,
)?
.reply()?;
let active_window = active_window_reply
.value32()
.ok_or(anyhow!(
"Error getting active window. Invalid response format."
))?
.next();
active_window.ok_or(anyhow!(
"Error getting active window. Received empty response."
))
}
}
fn monitor_info_to_physical_bounds(monitor: &MonitorInfo) -> PhysicalMonitorBounds {
let origin = PhysicalPosition::new(monitor.x, monitor.y);
let size = PhysicalSize::new(monitor.width, monitor.height);
(origin, size)
}
pub(super) fn physical_bounds_to_rect(bounds: &PhysicalMonitorBounds, scale_factor: f32) -> RectF {
let (origin, size) = bounds;
let origin = vec2f(origin.x as f32, origin.y as f32) / scale_factor;
let size = vec2f(size.width as f32, size.height as f32) / scale_factor;
RectF::new(origin, size)
}
/// Computes the area of a [`RectF`].
fn rect_area(rect: RectF) -> f32 {
rect.width() * rect.height()
}
@@ -0,0 +1,78 @@
use std::ops::Not;
use arboard::{self, Clipboard as WindowsClipboardInner};
use crate::{clipboard::ClipboardContent, Clipboard};
pub struct WindowsClipboard {
inner: WindowsClipboardInner,
}
impl WindowsClipboard {
pub fn new() -> Result<Self, arboard::Error> {
Ok(Self {
inner: WindowsClipboardInner::new()?,
})
}
}
impl Clipboard for WindowsClipboard {
fn write(&mut self, contents: ClipboardContent) {
let set_result = if let Some(html) = &contents.html {
self.inner.set().html(html, Some(&contents.plain_text))
} else {
self.inner.set().text(&contents.plain_text)
};
if let Err(err) = set_result {
if contents.html.is_some() {
log::warn!("Unable to set clipboard HTML: {err:?}");
} else {
log::warn!("Unable to set clipboard text: {err:?}");
}
}
}
fn read(&mut self) -> ClipboardContent {
let mut content = ClipboardContent {
plain_text: self.inner.get().text().unwrap_or_default(),
..Default::default()
};
// Try to get HTML content
if let Ok(html) = self.inner.get().html() {
content.html = html.is_empty().not().then_some(html);
}
// Some environments provide HTML but do not provide a plaintext representation.
// If that happens, derive a best-effort plaintext fallback from the HTML.
if content.plain_text.trim().is_empty() {
if let Some(html) = content.html.as_ref() {
let derived = crate::clipboard_utils::strip_html_to_plain_text(html);
if !derived.trim().is_empty() {
content.plain_text = derived;
}
}
}
// Get file paths.
content.paths = self.inner.get().file_list().ok().map(|list| {
list.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
});
// Try to get image content from clipboard
content.images = crate::clipboard_utils::read_images_from_clipboard(
&mut self.inner,
&content.html,
&content.plain_text,
);
content
}
}
#[cfg(test)]
#[path = "clipboard_tests.rs"]
mod tests;
@@ -0,0 +1,90 @@
/// Windows-specific clipboard tests.
///
/// Note: Most image processing functionality is tested in ui/src/clipboard_utils_tests.rs
/// to avoid duplication. These tests focus on Windows-specific clipboard behavior.
#[cfg(target_os = "windows")]
mod clipboard_tests {
use crate::windowing::winit::windows::clipboard::WindowsClipboard;
use crate::{clipboard::ClipboardContent, Clipboard};
fn create_test_clipboard() -> Option<WindowsClipboard> {
WindowsClipboard::new().ok()
}
#[test]
fn test_clipboard_round_trip() {
let mut clipboard = match create_test_clipboard() {
Some(clipboard) => clipboard,
None => {
eprintln!("Skipping test - no clipboard available (headless environment)");
return;
}
};
let test_content = ClipboardContent::plain_text("Windows clipboard test".to_string());
// Write content
clipboard.write(test_content.clone());
// Read it back
let read_content = clipboard.read();
// Should get the same text back (in environments where clipboard works)
if !read_content.plain_text.is_empty() {
assert_eq!(read_content.plain_text, test_content.plain_text);
}
}
#[test]
fn test_html_content_handling() {
let mut clipboard = match create_test_clipboard() {
Some(clipboard) => clipboard,
None => {
eprintln!("Skipping test - no clipboard available (headless environment)");
return;
}
};
let test_content = ClipboardContent {
plain_text: "Test text".to_string(),
html: Some("<div>Test HTML</div>".to_string()),
images: None,
paths: None,
};
// Write HTML content
clipboard.write(test_content.clone());
// Read it back
let read_content = clipboard.read();
// In environments where clipboard works, we should get content back
// (the exact HTML may not be preserved depending on the system)
if !read_content.is_empty() {
assert!(!read_content.plain_text.is_empty());
}
}
#[test]
fn test_empty_content_handling() {
let mut clipboard = match create_test_clipboard() {
Some(clipboard) => clipboard,
None => {
eprintln!("Skipping test - no clipboard available (headless environment)");
return;
}
};
let empty_content = ClipboardContent::plain_text("".to_string());
// Writing empty content should not panic
clipboard.write(empty_content);
// Reading should return valid ClipboardContent (may be empty or have previous content)
let read_content = clipboard.read();
// Should always return a valid ClipboardContent struct
// Test that the structure itself is valid, not the content
assert!(matches!(read_content.images, None | Some(_)));
}
}
@@ -0,0 +1,13 @@
pub mod clipboard;
mod network;
mod registry;
mod system_caption_buttons;
mod window_attribute;
mod window_ext;
pub use clipboard::*;
pub use network::*;
pub use registry::*;
pub use system_caption_buttons::*;
pub use window_attribute::*;
pub use window_ext::WindowExt;
@@ -0,0 +1,108 @@
use crate::windowing::winit::app::CustomEvent;
use anyhow::Context;
use windows::core::{implement, Interface};
use windows::Win32::Networking::NetworkListManager::{
INetworkListManager, INetworkListManagerEvents, INetworkListManagerEvents_Impl,
NetworkListManager, NLM_CONNECTIVITY, NLM_CONNECTIVITY_DISCONNECTED,
NLM_CONNECTIVITY_IPV4_INTERNET, NLM_CONNECTIVITY_IPV6_INTERNET,
};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, IConnectionPoint, IConnectionPointContainer, CLSCTX_ALL,
COINIT_APARTMENTTHREADED,
};
/// Implements the INetworkListManagerEvents trait so we can pass along connectivity events from Windows
/// OS to our winit event loop.
#[implement(INetworkListManagerEvents)]
#[allow(non_camel_case_types)]
struct WindowsNetworkListener {
event_loop: winit::event_loop::EventLoopProxy<CustomEvent>,
}
impl WindowsNetworkListener {
fn new(event_loop: winit::event_loop::EventLoopProxy<CustomEvent>) -> Self {
Self { event_loop }
}
}
#[allow(non_snake_case)]
impl INetworkListManagerEvents_Impl for WindowsNetworkListener_Impl {
fn ConnectivityChanged(&self, new_connectivity: NLM_CONNECTIVITY) -> windows::core::Result<()> {
// The NLM_CONNECTIVITY parameter is a bitmap. When it contains NLM_CONNECTIVITY_IPV4_INTERNET
// or NLM_CONNECTIVITY_IPV6_INTERNET, there's a connection. When it's equal to
// NLM_CONNECTIVITY_DISCONNECTED, it's a disconnection. Other arbitrary network events are ignored.
// https://learn.microsoft.com/en-us/windows/win32/api/netlistmgr/ne-netlistmgr-nlm_connectivity#syntax
// let connected = new_connectivity.eq(&NLM_CONNECTIVITY_IPV6_INTERNET) || new_connectivity.eq(&NLM_CONNECTIVITY_IPV4_INTERNET);
let connected = (new_connectivity.0
& (NLM_CONNECTIVITY_IPV6_INTERNET.0 | NLM_CONNECTIVITY_IPV4_INTERNET.0))
!= 0;
let disconnected = new_connectivity.eq(&NLM_CONNECTIVITY_DISCONNECTED);
if connected {
let _ = self.event_loop.send_event(CustomEvent::InternetConnected);
} else if disconnected {
let _ = self
.event_loop
.send_event(CustomEvent::InternetDisconnected);
}
Ok(())
}
}
pub struct WindowsNetworkConnectionPoint {
connection_point: IConnectionPoint,
cookie: u32,
#[allow(unused)]
/// We keep the events interface around for the duration of the program because
/// we're not sure we don't need it to keep living.
events_interface: INetworkListManagerEvents,
}
impl WindowsNetworkConnectionPoint {
pub fn clean_up(&self) {
unsafe {
if let Err(e) = self.connection_point.Unadvise(self.cookie) {
log::warn!("Failed to clean up network connection point: {e:?}");
}
}
}
}
pub fn add_network_connection_listener(
event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>,
) -> anyhow::Result<WindowsNetworkConnectionPoint> {
let network_listener = {
unsafe {
// This invocation matches winit exactly. We want to make sure we don't modify any winit invariants in the case that
// winit also calls CoInitializeEx.
// https://github.com/rust-windowing/winit/blob/953d9b426886749e2f88250f420c87db58080c97/src/platform_impl/windows/window.rs#L1386
CoInitializeEx(None, COINIT_APARTMENTTHREADED)
.ok()
.context("Failed to initialize COM")?;
let events_interface: INetworkListManagerEvents =
WindowsNetworkListener::new(event_loop_proxy).into();
let connection_point_container: IConnectionPointContainer =
CoCreateInstance(&NetworkListManager, None, CLSCTX_ALL)
.and_then(|network_manager: INetworkListManager| network_manager.cast())
.context("Failed to construct IConnectionPointContainer")?;
let connection_point: IConnectionPoint = connection_point_container
.FindConnectionPoint(&INetworkListManagerEvents::IID)
.context("Failed to construct IConnectionPoint")?;
let cookie = connection_point
.Advise(&events_interface)
.context("Failed to attach point and sink")?;
WindowsNetworkConnectionPoint {
connection_point,
cookie,
events_interface,
}
}
};
Ok(network_listener)
}
@@ -0,0 +1,22 @@
use crate::platform::SystemTheme;
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
const SYSTEM_THEME_SUBKEY_PATH: &str =
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
const LIGHT_MODE_SUBKEY_NAME: &str = "AppsUseLightTheme";
/// Retrieves the system theme from the Windows Registry.
/// https://github.com/wez/wezterm/blob/b8f94c474ce48ac195b51c1aeacf41ae049b774e/window/src/os/windows/connection.rs#L42
pub fn get_system_theme() -> Result<SystemTheme, std::io::Error> {
let theme_subkey = RegKey::predef(HKEY_CURRENT_USER).open_subkey(SYSTEM_THEME_SUBKEY_PATH)?;
let theme_value = theme_subkey.get_value::<u32, _>(LIGHT_MODE_SUBKEY_NAME)?;
match theme_value {
1 => Ok(SystemTheme::Light),
0 => Ok(SystemTheme::Dark),
_ => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("System theme value {theme_value:?} was invalid"),
)),
}
}
@@ -0,0 +1,42 @@
use super::window_attribute::get_window_attribute;
use super::WindowAttributeErr;
use windows::Win32::Foundation::RECT;
use windows::Win32::Graphics::Dwm;
use winit::window::Window as WinitWindow;
#[derive(Debug)]
pub struct SystemCaptionButtonData {
bounds: RECT,
}
#[derive(Debug)]
pub enum SystemCaptionButtonSide {
Left,
Right,
}
impl SystemCaptionButtonData {
pub fn total_width(&self) -> i32 {
self.bounds.right - self.bounds.left
}
pub fn side(&self) -> SystemCaptionButtonSide {
if self.bounds.left == 0 {
SystemCaptionButtonSide::Left
} else {
SystemCaptionButtonSide::Right
}
}
}
/// Retrieves the system caption button's bounds using the window's
/// CAPTION_BUTTON_BOUNDS attribute.
pub fn get_system_caption_button_bounds(
window: &WinitWindow,
) -> Result<SystemCaptionButtonData, WindowAttributeErr> {
let caption_button_bounds = get_window_attribute(window, Dwm::DWMWA_CAPTION_BUTTON_BOUNDS)?;
Ok(SystemCaptionButtonData {
bounds: caption_button_bounds,
})
}
@@ -0,0 +1,72 @@
use std::{ffi::c_void, mem::size_of};
use thiserror::Error;
use wgpu::rwh;
use windows::Win32::Foundation::HWND;
use windows::Win32::Graphics::Dwm::{self, DWMWINDOWATTRIBUTE};
use winit::raw_window_handle::HasWindowHandle;
use winit::raw_window_handle::RawWindowHandle;
use winit::window::Window as WinitWindow;
#[derive(Debug, Error)]
pub enum WindowAttributeErr {
#[error(transparent)]
HandleError(#[from] rwh::HandleError),
#[error(transparent)]
Win32Error(#[from] windows::core::Error),
}
/// Uses the `windows` crate to fetch a specific window attribute.
/// First, we translate the Winit window object to a native Windows HWND handle.
/// Then, we invoke the Device Window Manager (DWM)'s `DwmGetWindowAttribute`
/// function for the attribute in question.
pub fn get_window_attribute<T>(
window: &WinitWindow,
attribute_name: DWMWINDOWATTRIBUTE,
) -> Result<T, WindowAttributeErr>
where
T: Default,
{
let hwnd_handle = to_hwnd(window)?;
let mut result_destination: T = T::default();
let window_attribute_result = unsafe {
let result_address = core::ptr::addr_of_mut!(result_destination);
Dwm::DwmGetWindowAttribute(
hwnd_handle,
attribute_name,
result_address as *mut c_void,
size_of::<T>().try_into().unwrap(),
)
};
Ok(window_attribute_result.map(|_| result_destination)?)
}
/// Uses the `windows` crate to set a specific window attribute.
/// First, we translate the Winit window object to a native Windows HWND handle.
/// Then, we invoke the Device Window Manager (DWM)'s `DwmSetWindowAttribute`
/// function for the attribute in question.
pub fn set_window_attribute<T>(
window: &WinitWindow,
attribute_name: DWMWINDOWATTRIBUTE,
value: T,
) -> Result<(), WindowAttributeErr> {
let hwnd_handle = to_hwnd(window)?;
let window_attribute_result = unsafe {
Dwm::DwmSetWindowAttribute(
hwnd_handle,
attribute_name,
core::ptr::addr_of!(value) as *const c_void,
size_of::<T>().try_into().unwrap(),
)
};
Ok(window_attribute_result?)
}
fn to_hwnd(window: &WinitWindow) -> Result<HWND, rwh::HandleError> {
window
.window_handle()
.and_then(|handle| match handle.as_raw() {
RawWindowHandle::Win32(handle) => Ok(handle),
_ => Err(rwh::HandleError::NotSupported),
})
.map(|handle| HWND(handle.hwnd.get() as *mut c_void))
}
@@ -0,0 +1,42 @@
use windows::Win32::Foundation::{FALSE, HWND, TRUE};
use windows::Win32::Graphics::Dwm::{DwmSetWindowAttribute, DWMWA_CLOAK};
use windows_core::BOOL;
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
use winit::window::Window;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Invalid WindowHandle")]
InvalidWindowHandle,
#[error("Unknown error")]
Other(#[from] windows::core::Error),
}
/// Extension trait for Windows specific logic on a [`winit::window::Window`].
pub trait WindowExt {
/// "Cloaks" the window. A cloaked window is one that is invisible, but can still be drawn to.
fn set_cloaked(&self, cloaked: bool) -> Result<(), Error>;
}
impl WindowExt for Window {
fn set_cloaked(&self, cloaked: bool) -> Result<(), Error> {
let Ok(RawWindowHandle::Win32(handle)) = self
.window_handle()
.map(|window_handle| window_handle.as_raw())
else {
return Err(Error::InvalidWindowHandle);
};
let value = if cloaked { TRUE } else { FALSE };
unsafe {
DwmSetWindowAttribute(
HWND(handle.hwnd.get() as _),
DWMWA_CLOAK,
&value as *const BOOL as *const _,
size_of::<BOOL>() as u32,
)?
}
Ok(())
}
}