Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
#[cfg(winit)]
|
||||
pub mod winit;
|
||||
|
||||
pub use galaxyui_core::windowing::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use winit::WindowingSystem;
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user