mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-15 19:17:42 +01:00
feat: extension Window Management for macOS (#892)
* feat: extension Window Management for macOS * release note * revert frontend code changes * new line char * remove todo * it is macos-only * format code * macos-only * more conditional compilation * correct field Document.icon
This commit is contained in:
@@ -24,6 +24,7 @@ Information about release notes of Coco App is provided here.
|
||||
- feat: support context menu in debug mode #882
|
||||
- feat: file search for Linux/GNOME #884
|
||||
- feat: file search for Linux/KDE #886
|
||||
- feat: extension Window Management for macOS #892
|
||||
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
2183
src-tauri/Cargo.lock
generated
2183
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -109,11 +109,16 @@ indexmap = { version = "2.10.0", features = ["serde"] }
|
||||
strum = { version = "0.27.2", features = ["derive"] }
|
||||
sys-locale = "0.3.2"
|
||||
tauri-plugin-prevent-default = "1"
|
||||
oneshot = "0.1.11"
|
||||
bitflags = "2.9.3"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
|
||||
objc2 = "0.6.2"
|
||||
objc2-core-foundation = {version = "0.3.1", features = ["CFString", "CFCGTypes", "CFArray"] }
|
||||
objc2-application-services = { version = "0.3.1", features = ["HIServices"] }
|
||||
objc2-core-graphics = { version = "=0.3.1", features = ["CGEvent"] }
|
||||
|
||||
[target."cfg(target_os = \"linux\")".dependencies]
|
||||
gio = "0.20.12"
|
||||
@@ -122,6 +127,8 @@ which = "8.0.0"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
serde = { version = "1.0.219", features = ["derive"], optional = true }
|
||||
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::extension::ExtensionSettings;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::extension::built_in::window_management::actions::Action;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
@@ -43,6 +45,9 @@ pub(crate) enum OnOpened {
|
||||
Application { app_path: String },
|
||||
/// Open the URL.
|
||||
Document { url: String },
|
||||
/// Perform this WM action.
|
||||
#[cfg(target_os = "macos")]
|
||||
WindowManagementAction { action: Action },
|
||||
/// The document is an extension.
|
||||
Extension(ExtensionOnOpened),
|
||||
}
|
||||
@@ -81,6 +86,11 @@ impl OnOpened {
|
||||
match self {
|
||||
Self::Application { app_path } => app_path.clone(),
|
||||
Self::Document { url } => url.clone(),
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::WindowManagementAction { action: _ } => {
|
||||
// We don't have URL for this
|
||||
String::from("N/A")
|
||||
}
|
||||
Self::Extension(ext_on_opened) => {
|
||||
match &ext_on_opened.ty {
|
||||
ExtensionOnOpenedType::Command { action } => {
|
||||
@@ -123,6 +133,15 @@ pub(crate) async fn open(
|
||||
|
||||
homemade_tauri_shell_open(tauri_app_handle.clone(), url).await?
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
OnOpened::WindowManagementAction { action } => {
|
||||
log::debug!("perform Window Management action [{:?}]", action);
|
||||
|
||||
crate::extension::built_in::window_management::perform_action_on_main_thread(
|
||||
&tauri_app_handle,
|
||||
action,
|
||||
)?;
|
||||
}
|
||||
OnOpened::Extension(ext_on_opened) => {
|
||||
// Apply the settings that would affect open behavior
|
||||
if let Some(settings) = ext_on_opened.settings {
|
||||
|
||||
@@ -6,6 +6,8 @@ pub mod calculator;
|
||||
pub mod file_search;
|
||||
pub mod pizza_engine_runtime;
|
||||
pub mod quick_ai_access;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod window_management;
|
||||
|
||||
use super::Extension;
|
||||
use crate::SearchSourceRegistry;
|
||||
@@ -181,6 +183,19 @@ pub(crate) async fn list_built_in_extensions(
|
||||
.await?,
|
||||
);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
window_management::EXTENSION_ID,
|
||||
window_management::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(built_in_extensions)
|
||||
}
|
||||
|
||||
@@ -215,6 +230,20 @@ pub(super) async fn init_built_in_extension(
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if extension.id == window_management::EXTENSION_ID {
|
||||
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
|
||||
window_management::set_up_commands_hotkeys(tauri_app_handle, extension)?;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -303,6 +332,40 @@ pub(crate) async fn enable_built_in_extension(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
|
||||
let file_system_search = window_management::search_source::WindowManagementSearchSource;
|
||||
search_source_registry_tauri_state
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::set_up_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::set_up_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -386,6 +449,36 @@ pub(crate) async fn disable_built_in_extension(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(bundle_id.extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::unset_commands_hotkeys(tauri_app_handle, &extension)?;
|
||||
}
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
let built_in_extension_dir = get_built_in_extension_directory(tauri_app_handle);
|
||||
alter_extension_json_file(&built_in_extension_dir, bundle_id, update_extension)?;
|
||||
|
||||
let extension =
|
||||
load_extension_from_json_file(&built_in_extension_dir, bundle_id.extension_id)?;
|
||||
window_management::unset_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -393,12 +486,32 @@ pub(crate) fn set_built_in_extension_alias(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
alias: &str,
|
||||
) {
|
||||
) -> Result<(), String> {
|
||||
if bundle_id.extension_id == application::QUERYSOURCE_ID_DATASOURCE_ID_DATASOURCE_NAME {
|
||||
if let Some(app_path) = bundle_id.sub_extension_id {
|
||||
application::set_app_alias(tauri_app_handle, app_path, alias);
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_some()
|
||||
{
|
||||
let update_function = |ext: &mut Extension| {
|
||||
ext.alias = Some(alias.to_string());
|
||||
Ok(())
|
||||
};
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_function,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn register_built_in_extension_hotkey(
|
||||
@@ -411,6 +524,29 @@ pub(crate) fn register_built_in_extension_hotkey(
|
||||
application::register_app_hotkey(&tauri_app_handle, app_path, hotkey)?;
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let update_function = |ext: &mut Extension| {
|
||||
ext.hotkey = Some(hotkey.into());
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_function,
|
||||
)?;
|
||||
|
||||
window_management::register_command_hotkey(tauri_app_handle, command_id, hotkey)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -423,6 +559,35 @@ pub(crate) fn unregister_built_in_extension_hotkey(
|
||||
application::unregister_app_hotkey(&tauri_app_handle, app_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
let update_function = |ext: &mut Extension| {
|
||||
ext.hotkey = None;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID {
|
||||
if let Some(command_id) = bundle_id.sub_extension_id {
|
||||
|
||||
let extension = load_extension_from_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id.extension_id,
|
||||
)
|
||||
.unwrap();
|
||||
window_management::unregister_command_hotkey(tauri_app_handle, &extension, command_id)?;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_function,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -462,6 +627,8 @@ fn load_extension_from_json_file(
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
#[allow(unused_macros)] // #[function_name::named] only used on macOS
|
||||
#[function_name::named]
|
||||
pub(crate) async fn is_built_in_extension_enabled(
|
||||
tauri_app_handle: &AppHandle,
|
||||
bundle_id: &ExtensionBundleIdBorrowed<'_>,
|
||||
@@ -514,5 +681,38 @@ pub(crate) async fn is_built_in_extension_enabled(
|
||||
.is_some());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
// Window Management
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(bundle_id.extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
// Window Management commands
|
||||
if bundle_id.extension_id == window_management::EXTENSION_ID
|
||||
&& let Some(command_id) = bundle_id.sub_extension_id
|
||||
{
|
||||
let extension = load_extension_from_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id.extension_id,
|
||||
)?;
|
||||
let commands = extension
|
||||
.commands
|
||||
.expect("window management extension has commands");
|
||||
|
||||
let extension = commands.iter().find( |cmd| cmd.id == command_id).unwrap_or_else(|| {
|
||||
panic!("function [{}()] invoked with a Window Management command that does not exist, extension ID [{}] ", function_name!(), command_id)
|
||||
});
|
||||
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!("extension [{:?}] is not a built-in extension", bundle_id)
|
||||
}
|
||||
|
||||
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
134
src-tauri/src/extension/built_in/window_management/actions.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
#[derive(Debug, Clone, PartialEq, Copy, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum Action {
|
||||
/// Move the window to fill left half of the screen.
|
||||
TopHalf,
|
||||
/// Move the window to fill bottom half of the screen.
|
||||
BottomHalf,
|
||||
/// Move the window to fill left half of the screen.
|
||||
LeftHalf,
|
||||
/// Move the window to fill right half of the screen.
|
||||
RightHalf,
|
||||
/// Move the window to fill center half of the screen.
|
||||
CenterHalf,
|
||||
|
||||
/// Resize window to the top left quarter of the screen.
|
||||
TopLeftQuarter,
|
||||
/// Resize window to the top right quarter of the screen.
|
||||
TopRightQuarter,
|
||||
/// Resize window to the bottom left quarter of the screen.
|
||||
BottomLeftQuarter,
|
||||
/// Resize window to the bottom right quarter of the screen.
|
||||
BottomRightQuarter,
|
||||
|
||||
/// Resize window to the top left sixth of the screen.
|
||||
TopLeftSixth,
|
||||
/// Resize window to the top center sixth of the screen.
|
||||
TopCenterSixth,
|
||||
/// Resize window to the top right sixth of the screen.
|
||||
TopRightSixth,
|
||||
/// Resize window to the bottom left sixth of the screen.
|
||||
BottomLeftSixth,
|
||||
/// Resize window to the bottom center sixth of the screen.
|
||||
BottomCenterSixth,
|
||||
/// Resize window to the bottom right sixth of the screen.
|
||||
BottomRightSixth,
|
||||
|
||||
/// Resize window to the top third of the screen.
|
||||
TopThird,
|
||||
/// Resize window to the middle third of the screen.
|
||||
MiddleThird,
|
||||
/// Resize window to the bottom third of the screen.
|
||||
BottomThird,
|
||||
|
||||
/// Center window in the screen.
|
||||
Center,
|
||||
|
||||
/// Resize window to the first fourth of the screen.
|
||||
FirstFourth,
|
||||
/// Resize window to the second fourth of the screen.
|
||||
SecondFourth,
|
||||
/// Resize window to the third fourth of the screen.
|
||||
ThirdFourth,
|
||||
/// Resize window to the last fourth of the screen.
|
||||
LastFourth,
|
||||
|
||||
/// Resize window to the first third of the screen.
|
||||
FirstThird,
|
||||
/// Resize window to the center third of the screen.
|
||||
CenterThird,
|
||||
/// Resize window to the last third of the screen.
|
||||
LastThird,
|
||||
|
||||
/// Resize window to the first two thirds of the screen.
|
||||
FirstTwoThirds,
|
||||
/// Resize window to the center two thirds of the screen.
|
||||
CenterTwoThirds,
|
||||
/// Resize window to the last two thirds of the screen.
|
||||
LastTwoThirds,
|
||||
|
||||
/// Resize window to the first three fourths of the screen.
|
||||
FirstThreeFourths,
|
||||
/// Resize window to the center three fourths of the screen.
|
||||
CenterThreeFourths,
|
||||
/// Resize window to the last three fourths of the screen.
|
||||
LastThreeFourths,
|
||||
|
||||
/// Resize window to the top three fourths of the screen.
|
||||
TopThreeFourths,
|
||||
/// Resize window to the bottom three fourths of the screen.
|
||||
BottomThreeFourths,
|
||||
|
||||
/// Resize window to the top two thirds of the screen.
|
||||
TopTwoThirds,
|
||||
/// Resize window to the bottom two thirds of the screen.
|
||||
BottomTwoThirds,
|
||||
/// Resize window to the top center two thirds of the screen.
|
||||
TopCenterTwoThirds,
|
||||
|
||||
/// Resize window to the top first fourth of the screen.
|
||||
TopFirstFourth,
|
||||
/// Resize window to the top second fourth of the screen.
|
||||
TopSecondFourth,
|
||||
/// Resize window to the top third fourth of the screen.
|
||||
TopThirdFourth,
|
||||
/// Resize window to the top last fourth of the screen.
|
||||
TopLastFourth,
|
||||
|
||||
/// Increase the window until it reaches the screen size.
|
||||
MakeLarger,
|
||||
/// Decrease the window until it reaches its minimal size.
|
||||
MakeSmaller,
|
||||
|
||||
/// Maximize window to almost fit the screen.
|
||||
AlmostMaximize,
|
||||
/// Maximize window to fit the screen.
|
||||
Maximize,
|
||||
/// Maximize width of window to fit the screen.
|
||||
MaximizeWidth,
|
||||
/// Maximize height of window to fit the screen.
|
||||
MaximizeHeight,
|
||||
|
||||
/// Move window to the top edge of the screen.
|
||||
MoveUp,
|
||||
/// Move window to the bottom of the screen.
|
||||
MoveDown,
|
||||
/// Move window to the left edge of the screen.
|
||||
MoveLeft,
|
||||
/// Move window to the right edge of the screen.
|
||||
MoveRight,
|
||||
|
||||
/// Move window to the next desktop.
|
||||
NextDesktop,
|
||||
/// Move window to the previous desktop.
|
||||
PreviousDesktop,
|
||||
/// Move window to the next display.
|
||||
NextDisplay,
|
||||
/// Move window to the previous display.
|
||||
PreviousDisplay,
|
||||
|
||||
/// Restore window to its last position.
|
||||
Restore,
|
||||
|
||||
/// Toggle fullscreen mode.
|
||||
ToggleFullscreen,
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
//! This module calls macOS APIs to implement various helper functions needed by
|
||||
//! to perform the defined actions.
|
||||
|
||||
mod private;
|
||||
|
||||
use std::ffi::c_uint;
|
||||
use std::ffi::c_ushort;
|
||||
use std::ffi::c_void;
|
||||
use std::ops::Deref;
|
||||
use std::ptr::NonNull;
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSEvent;
|
||||
use objc2_app_kit::NSScreen;
|
||||
use objc2_app_kit::NSWorkspace;
|
||||
use objc2_application_services::AXError;
|
||||
use objc2_application_services::AXUIElement;
|
||||
use objc2_application_services::AXValue;
|
||||
use objc2_application_services::AXValueType;
|
||||
use objc2_core_foundation::CFBoolean;
|
||||
use objc2_core_foundation::CFRetained;
|
||||
use objc2_core_foundation::CFString;
|
||||
use objc2_core_foundation::CFType;
|
||||
use objc2_core_foundation::CGPoint;
|
||||
use objc2_core_foundation::CGRect;
|
||||
use objc2_core_foundation::CGSize;
|
||||
use objc2_core_foundation::Type;
|
||||
use objc2_core_foundation::{CFArray, CFDictionary, CFNumber};
|
||||
use objc2_core_graphics::CGError;
|
||||
use objc2_core_graphics::CGEvent;
|
||||
use objc2_core_graphics::CGEventFlags;
|
||||
use objc2_core_graphics::CGEventTapLocation;
|
||||
use objc2_core_graphics::CGEventType;
|
||||
use objc2_core_graphics::CGMouseButton;
|
||||
use objc2_core_graphics::CGRectGetMidX;
|
||||
use objc2_core_graphics::CGRectGetMinY;
|
||||
use objc2_core_graphics::CGWindowID;
|
||||
|
||||
use super::error::Error;
|
||||
|
||||
use private::CGSCopyManagedDisplaySpaces;
|
||||
use private::CGSGetActiveSpace;
|
||||
use private::CGSMainConnectionID;
|
||||
use private::CGSSpaceID;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
fn intersects(r1: CGRect, r2: CGRect) -> bool {
|
||||
let overlapping = !(r1.origin.x + r1.size.width < r2.origin.x
|
||||
|| r1.origin.y + r1.size.height < r2.origin.y
|
||||
|| r1.origin.x > r2.origin.x + r2.size.width
|
||||
|| r1.origin.y > r2.origin.y + r2.size.height);
|
||||
|
||||
overlapping
|
||||
}
|
||||
|
||||
/// Core graphics APIs use flipped coordinate system, while AppKit uses the
|
||||
/// unflippled version, they differ in the y-axis. We need to do the conversion
|
||||
/// (to `CGPoint.y`) manually.
|
||||
fn flip_frame_y(main_screen_height: f64, frame_height: f64, frame_unflipped_y: f64) -> f64 {
|
||||
main_screen_height - (frame_unflipped_y + frame_height)
|
||||
}
|
||||
|
||||
/// Helper function to extract an UI element's origin.
|
||||
fn get_ui_element_origin(ui_element: &CFRetained<AXUIElement>) -> Result<CGPoint, Error> {
|
||||
let mut position_value: *const CFType = std::ptr::null();
|
||||
let ptr_to_position_value = NonNull::new(&mut position_value).unwrap();
|
||||
let position_attr = CFString::from_static_str("AXPosition");
|
||||
let error = unsafe { ui_element.copy_attribute_value(&position_attr, ptr_to_position_value) };
|
||||
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
assert!(!position_value.is_null());
|
||||
|
||||
let position: CFRetained<AXValue> =
|
||||
unsafe { CFRetained::from_raw(NonNull::new(position_value.cast_mut().cast()).unwrap()) };
|
||||
|
||||
let mut position_cg_point = CGPoint::ZERO;
|
||||
let ptr_to_position_cg_point =
|
||||
NonNull::new((&mut position_cg_point as *mut CGPoint).cast()).unwrap();
|
||||
|
||||
let result = unsafe { position.value(AXValueType::CGPoint, ptr_to_position_cg_point) };
|
||||
assert!(result, "type mismatched");
|
||||
|
||||
Ok(position_cg_point)
|
||||
}
|
||||
|
||||
/// Helper function to extract an UI element's size.
|
||||
fn get_ui_element_size(ui_element: &CFRetained<AXUIElement>) -> Result<CGSize, Error> {
|
||||
let mut size_value: *const CFType = std::ptr::null();
|
||||
let ptr_to_size_value = NonNull::new(&mut size_value).unwrap();
|
||||
let size_attr = CFString::from_static_str("AXSize");
|
||||
let error = unsafe { ui_element.copy_attribute_value(&size_attr, ptr_to_size_value) };
|
||||
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
assert!(!size_value.is_null());
|
||||
|
||||
let size: CFRetained<AXValue> =
|
||||
unsafe { CFRetained::from_raw(NonNull::new(size_value.cast_mut().cast()).unwrap()) };
|
||||
|
||||
let mut size_cg_size = CGSize::ZERO;
|
||||
let ptr_to_size_cg_size = NonNull::new((&mut size_cg_size as *mut CGSize).cast()).unwrap();
|
||||
|
||||
let result = unsafe { size.value(AXValueType::CGSize, ptr_to_size_cg_size) };
|
||||
assert!(result, "type mismatched");
|
||||
|
||||
Ok(size_cg_size)
|
||||
}
|
||||
|
||||
/// Get the frontmost/focused window (as an UI element).
|
||||
fn get_frontmost_window() -> Result<CFRetained<AXUIElement>, Error> {
|
||||
let workspace = unsafe { NSWorkspace::sharedWorkspace() };
|
||||
let frontmost_app =
|
||||
unsafe { workspace.frontmostApplication() }.ok_or(Error::CannotFindFocusWindow)?;
|
||||
|
||||
let pid = unsafe { frontmost_app.processIdentifier() };
|
||||
|
||||
let app_element = unsafe { AXUIElement::new_application(pid) };
|
||||
|
||||
let mut window_element: *const CFType = std::ptr::null();
|
||||
let ptr_to_window_element = NonNull::new(&mut window_element).unwrap();
|
||||
let focused_window_attr = CFString::from_static_str("AXFocusedWindow");
|
||||
|
||||
let error =
|
||||
unsafe { app_element.copy_attribute_value(&focused_window_attr, ptr_to_window_element) };
|
||||
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
assert!(!window_element.is_null());
|
||||
|
||||
let window_element: *mut AXUIElement = window_element.cast::<AXUIElement>().cast_mut();
|
||||
|
||||
let window = unsafe { CFRetained::from_raw(NonNull::new(window_element).unwrap()) };
|
||||
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// Get the CGWindowID of the frontmost/focused window.
|
||||
#[allow(unused)] // In case we need it in the future
|
||||
pub(crate) fn get_frontmost_window_id() -> Result<CGWindowID, Error> {
|
||||
let element = get_frontmost_window()?;
|
||||
let ptr: NonNull<AXUIElement> = CFRetained::as_ptr(&element);
|
||||
|
||||
let mut window_id_buffer: CGWindowID = 0;
|
||||
let error =
|
||||
unsafe { private::_AXUIElementGetWindow(ptr.as_ptr(), &mut window_id_buffer as *mut _) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
|
||||
Ok(window_id_buffer)
|
||||
}
|
||||
|
||||
/// Returns the workspace ID list grouped by display. For example, suppose you
|
||||
/// have 2 displays and 10 workspaces (5 workspaces per display), then this
|
||||
/// function might return something like:
|
||||
///
|
||||
/// ```text
|
||||
/// [
|
||||
/// [8, 11, 12, 13, 24],
|
||||
/// [519, 77, 15, 249, 414]
|
||||
/// ]
|
||||
/// ```
|
||||
///
|
||||
/// Even though this function return macOS internal space IDs, they should correspond
|
||||
/// to the logical workspace that users are familiar with. The display that contains
|
||||
/// workspaces `[8, 11, 12, 13, 24]` should be your main display; workspace 8 represents
|
||||
/// Desktop 1, and workspace 414 represents Desktop 10.
|
||||
fn workspace_ids_grouped_by_display() -> Vec<Vec<CGSSpaceID>> {
|
||||
unsafe {
|
||||
let mut ret = Vec::new();
|
||||
let conn = CGSMainConnectionID();
|
||||
|
||||
let display_spaces_raw = CGSCopyManagedDisplaySpaces(conn);
|
||||
let display_spaces: CFRetained<CFArray> =
|
||||
CFRetained::from_raw(NonNull::new(display_spaces_raw).unwrap());
|
||||
|
||||
let key_spaces: CFRetained<CFString> = CFString::from_static_str("Spaces");
|
||||
let key_spaces_ptr: NonNull<CFString> = CFRetained::as_ptr(&key_spaces);
|
||||
let key_id64: CFRetained<CFString> = CFString::from_static_str("id64");
|
||||
let key_id64_ptr: NonNull<CFString> = CFRetained::as_ptr(&key_id64);
|
||||
|
||||
for i in 0..display_spaces.count() {
|
||||
let mut workspaces_of_this_display = Vec::new();
|
||||
|
||||
let dict_ref = display_spaces.value_at_index(i);
|
||||
let dict: &CFDictionary = &*(dict_ref as *const CFDictionary);
|
||||
|
||||
let mut ptr_to_value_buffer: *const c_void = std::ptr::null();
|
||||
let key_exists = dict.value_if_present(
|
||||
key_spaces_ptr.as_ptr().cast::<c_void>().cast_const(),
|
||||
&mut ptr_to_value_buffer as *mut _,
|
||||
);
|
||||
assert!(key_exists);
|
||||
assert!(!ptr_to_value_buffer.is_null());
|
||||
|
||||
let spaces_raw: *const CFArray = ptr_to_value_buffer.cast::<CFArray>();
|
||||
|
||||
let spaces = &*spaces_raw;
|
||||
|
||||
for idx in 0..spaces.count() {
|
||||
let workspace_dictionary: &CFDictionary =
|
||||
&*spaces.value_at_index(idx).cast::<CFDictionary>();
|
||||
|
||||
let mut ptr_to_value_buffer: *const c_void = std::ptr::null();
|
||||
let key_exists = workspace_dictionary.value_if_present(
|
||||
key_id64_ptr.as_ptr().cast::<c_void>().cast_const(),
|
||||
&mut ptr_to_value_buffer as *mut _,
|
||||
);
|
||||
assert!(key_exists);
|
||||
assert!(!ptr_to_value_buffer.is_null());
|
||||
|
||||
let ptr_workspace_id = ptr_to_value_buffer.cast::<CFNumber>();
|
||||
let workspace_id = (&*ptr_workspace_id).as_i32().unwrap();
|
||||
|
||||
workspaces_of_this_display.push(workspace_id);
|
||||
}
|
||||
|
||||
ret.push(workspaces_of_this_display);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next workspace's logical ID. By logical ID, we mean the ID that
|
||||
/// users are familiar with, workspace 1/2/3 and so on, rather than the internal
|
||||
/// `CGSSpaceID`.
|
||||
///
|
||||
/// NOTE that this function returns None when the current workspace is the last
|
||||
/// workspace in the current display.
|
||||
pub(crate) fn get_next_workspace_logical_id() -> Option<usize> {
|
||||
let window_server_connection = unsafe { CGSMainConnectionID() };
|
||||
let current_workspace_id = unsafe { CGSGetActiveSpace(window_server_connection) };
|
||||
|
||||
// Logical ID starts from 1
|
||||
let mut logical_id = 1_usize;
|
||||
|
||||
for workspaces_in_a_display in workspace_ids_grouped_by_display() {
|
||||
for (idx, workspace_raw_id) in workspaces_in_a_display.iter().enumerate() {
|
||||
if *workspace_raw_id == current_workspace_id {
|
||||
// We found it, now check if it is the last workspace in this display
|
||||
if idx == workspaces_in_a_display.len() - 1 {
|
||||
return None;
|
||||
} else {
|
||||
return Some(logical_id + 1);
|
||||
}
|
||||
} else {
|
||||
logical_id += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!(
|
||||
"unless the private API CGSGetActiveSpace() is broken, it should return an ID that is in the workspace ID list"
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the previous workspace's logical ID.
|
||||
///
|
||||
/// See [`get_next_workspace_logical_id`] for the doc.
|
||||
pub(crate) fn get_previous_workspace_logical_id() -> Option<usize> {
|
||||
let window_server_connection = unsafe { CGSMainConnectionID() };
|
||||
let current_workspace_id = unsafe { CGSGetActiveSpace(window_server_connection) };
|
||||
|
||||
// Logical ID starts from 1
|
||||
let mut logical_id = 1_usize;
|
||||
|
||||
for workspaces_in_a_display in workspace_ids_grouped_by_display() {
|
||||
for (idx, workspace_raw_id) in workspaces_in_a_display.iter().enumerate() {
|
||||
if *workspace_raw_id == current_workspace_id {
|
||||
// We found it, now check if it is the first workspace in this display
|
||||
if idx == 0 {
|
||||
return None;
|
||||
} else {
|
||||
// this sub operation is safe, logical_id is at least 2
|
||||
return Some(logical_id - 1);
|
||||
}
|
||||
} else {
|
||||
logical_id += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!(
|
||||
"unless the private API CGSGetActiveSpace() is broken, it should return an ID that is in the workspace ID list"
|
||||
)
|
||||
}
|
||||
|
||||
/// Move the frontmost window to the specified workspace.
|
||||
///
|
||||
/// Credits to the Silica library
|
||||
///
|
||||
/// * https://github.com/ianyh/Silica/blob/b91a18dbb822e99ce6b487d1cb4841e863139b2a/Silica/Sources/SIWindow.m#L215-L260
|
||||
/// * https://github.com/ianyh/Silica/blob/b91a18dbb822e99ce6b487d1cb4841e863139b2a/Silica/Sources/SISystemWideElement.m#L29-L65
|
||||
pub(crate) fn move_frontmost_window_to_workspace(space: usize) -> Result<(), Error> {
|
||||
assert!(space >= 1);
|
||||
if space > 16 {
|
||||
return Err(Error::TooManyWorkspace);
|
||||
}
|
||||
|
||||
let window_frame = get_frontmost_window_frame()?;
|
||||
let close_button_frame = get_frontmost_window_close_button_frame()?;
|
||||
|
||||
let mouse_cursor_point = CGPoint::new(
|
||||
unsafe { CGRectGetMidX(close_button_frame) },
|
||||
window_frame.origin.y
|
||||
+ (window_frame.origin.y - unsafe { CGRectGetMinY(close_button_frame) }).abs() / 2.0,
|
||||
);
|
||||
|
||||
let mouse_move_event = unsafe {
|
||||
CGEvent::new_mouse_event(
|
||||
None,
|
||||
CGEventType::MouseMoved,
|
||||
mouse_cursor_point,
|
||||
CGMouseButton::Left,
|
||||
)
|
||||
};
|
||||
let mouse_drag_event = unsafe {
|
||||
CGEvent::new_mouse_event(
|
||||
None,
|
||||
CGEventType::LeftMouseDragged,
|
||||
mouse_cursor_point,
|
||||
CGMouseButton::Left,
|
||||
)
|
||||
};
|
||||
let mouse_down_event = unsafe {
|
||||
CGEvent::new_mouse_event(
|
||||
None,
|
||||
CGEventType::LeftMouseDown,
|
||||
mouse_cursor_point,
|
||||
CGMouseButton::Left,
|
||||
)
|
||||
};
|
||||
let mouse_up_event = unsafe {
|
||||
CGEvent::new_mouse_event(
|
||||
None,
|
||||
CGEventType::LeftMouseUp,
|
||||
mouse_cursor_point,
|
||||
CGMouseButton::Left,
|
||||
)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
CGEvent::set_flags(mouse_move_event.as_deref(), CGEventFlags(0));
|
||||
CGEvent::set_flags(mouse_down_event.as_deref(), CGEventFlags(0));
|
||||
CGEvent::set_flags(mouse_up_event.as_deref(), CGEventFlags(0));
|
||||
|
||||
// Move the mouse into place at the window's toolbar
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_move_event.as_deref());
|
||||
// Mouse down to set up the drag
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_down_event.as_deref());
|
||||
// Drag event to grab hold of the window
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_drag_event.as_deref());
|
||||
}
|
||||
|
||||
// cast is safe as space is in range [1, 16]
|
||||
let hot_key: c_ushort = 118 + space as c_ushort - 1;
|
||||
|
||||
let mut flags: c_uint = 0;
|
||||
let mut key_code: c_ushort = 0;
|
||||
let error = unsafe {
|
||||
private::CGSGetSymbolicHotKeyValue(hot_key, std::ptr::null_mut(), &mut key_code, &mut flags)
|
||||
};
|
||||
if error != CGError::Success {
|
||||
return Err(Error::CGError(error));
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// If the hotkey is disabled, enable it.
|
||||
if !private::CGSIsSymbolicHotKeyEnabled(hot_key) {
|
||||
if private::CGSSetSymbolicHotKeyEnabled(hot_key, true) != CGError::Success {
|
||||
return Err(Error::CGError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let opt_keyboard_event = unsafe { CGEvent::new_keyboard_event(None, key_code, true) };
|
||||
unsafe {
|
||||
// cast is safe (uint -> u64)
|
||||
CGEvent::set_flags(opt_keyboard_event.as_deref(), CGEventFlags(flags as u64));
|
||||
}
|
||||
|
||||
let keyboard_event = opt_keyboard_event.unwrap();
|
||||
let event = unsafe { NSEvent::eventWithCGEvent(&keyboard_event) }.unwrap();
|
||||
|
||||
let keyboard_event_up = unsafe { CGEvent::new_keyboard_event(None, event.keyCode(), false) };
|
||||
unsafe {
|
||||
CGEvent::set_flags(keyboard_event_up.as_deref(), CGEventFlags(0));
|
||||
|
||||
// Send the shortcut command to get Mission Control to switch spaces from under the window.
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, event.CGEvent().as_deref());
|
||||
CGEvent::post(
|
||||
CGEventTapLocation::HIDEventTap,
|
||||
keyboard_event_up.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
// Let go of the window.
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, mouse_up_event.as_deref());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn get_frontmost_window_origin() -> Result<CGPoint, Error> {
|
||||
let frontmost_window = get_frontmost_window()?;
|
||||
get_ui_element_origin(&frontmost_window)
|
||||
}
|
||||
|
||||
pub(crate) fn get_frontmost_window_size() -> Result<CGSize, Error> {
|
||||
let frontmost_window = get_frontmost_window()?;
|
||||
get_ui_element_size(&frontmost_window)
|
||||
}
|
||||
|
||||
pub(crate) fn get_frontmost_window_frame() -> Result<CGRect, Error> {
|
||||
let origin = get_frontmost_window_origin()?;
|
||||
let size = get_frontmost_window_size()?;
|
||||
|
||||
Ok(CGRect { origin, size })
|
||||
}
|
||||
|
||||
/// Get the frontmost window's close button, then extract its frame.
|
||||
fn get_frontmost_window_close_button_frame() -> Result<CGRect, Error> {
|
||||
let window = get_frontmost_window()?;
|
||||
|
||||
let mut ptr_to_close_button: *const CFType = std::ptr::null();
|
||||
let ptr_to_buffer = NonNull::new(&mut ptr_to_close_button).unwrap();
|
||||
|
||||
let close_button_attribute = CFString::from_static_str("AXCloseButton");
|
||||
let error = unsafe { window.copy_attribute_value(&close_button_attribute, ptr_to_buffer) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
assert!(!ptr_to_close_button.is_null());
|
||||
|
||||
let close_button_element = ptr_to_close_button.cast::<AXUIElement>().cast_mut();
|
||||
let close_button = unsafe { CFRetained::from_raw(NonNull::new(close_button_element).unwrap()) };
|
||||
|
||||
let origin = get_ui_element_origin(&close_button)?;
|
||||
let size = get_ui_element_size(&close_button)?;
|
||||
|
||||
Ok(CGRect { origin, size })
|
||||
}
|
||||
|
||||
/// This function returns the "visible frame" [^1] of all the screens.
|
||||
///
|
||||
/// FIXME: This function relies on the [`visibleFrame()`][vf_doc] API, which
|
||||
/// has 2 bugs we need to work around:
|
||||
///
|
||||
/// 1. It assumes the Dock is on the main display, which in reality depends on
|
||||
/// how users arrange their displays and the "Dock position on screen" setting
|
||||
/// entry.
|
||||
/// 2. For non-main displays, it assumes that they don't have a menu bar, but macOS
|
||||
/// puts a menu bar on every display.
|
||||
///
|
||||
///
|
||||
/// [^1]: Visible frame: a rectangle defines the portion of the screen in which it
|
||||
/// is currently safe to draw your app’s content.
|
||||
///
|
||||
/// [vf_doc]: https://developer.apple.com/documentation/AppKit/NSScreen/visibleFrame
|
||||
pub(crate) fn list_visible_frame_of_all_screens() -> Result<Vec<CGRect>, Error> {
|
||||
let main_thread_marker = MainThreadMarker::new().ok_or(Error::NotInMainThread)?;
|
||||
let screens = NSScreen::screens(main_thread_marker).to_vec();
|
||||
|
||||
if screens.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let main_screen = screens.first().expect("screens is not empty");
|
||||
|
||||
let frames = screens
|
||||
.iter()
|
||||
.map(|ns_screen| {
|
||||
// NSScreen is an AppKit API, which uses unflipped coordinate
|
||||
// system, flip it
|
||||
let mut unflipped_frame = ns_screen.visibleFrame();
|
||||
let flipped_frame_origin_y = flip_frame_y(
|
||||
main_screen.frame().size.height,
|
||||
unflipped_frame.size.height,
|
||||
unflipped_frame.origin.y,
|
||||
);
|
||||
unflipped_frame.origin.y = flipped_frame_origin_y;
|
||||
|
||||
unflipped_frame
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Get the Visible frame of the "active screen"[^1].
|
||||
///
|
||||
///
|
||||
/// [^1]: the screen which the frontmost window is on.
|
||||
pub(crate) fn get_active_screen_visible_frame() -> Result<CGRect, Error> {
|
||||
let main_thread_marker = MainThreadMarker::new().ok_or(Error::NotInMainThread)?;
|
||||
|
||||
let frontmost_window_frame = get_frontmost_window_frame()?;
|
||||
|
||||
let screens = NSScreen::screens(main_thread_marker)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if screens.is_empty() {
|
||||
return Err(Error::NoDisplay);
|
||||
}
|
||||
|
||||
let main_screen_height = screens[0].frame().size.height;
|
||||
|
||||
// AppKit uses Unflipped Coordinate System, but Accessibility APIs use
|
||||
// Flipped Coordinate System, we need to flip the origin of these screens.
|
||||
for screen in screens {
|
||||
let mut screen_frame = screen.frame();
|
||||
let unflipped_y = screen_frame.origin.y;
|
||||
let flipped_y = flip_frame_y(main_screen_height, screen_frame.size.height, unflipped_y);
|
||||
screen_frame.origin.y = flipped_y;
|
||||
|
||||
if intersects(screen_frame, frontmost_window_frame) {
|
||||
let mut visible_frame = screen.visibleFrame();
|
||||
let flipped_y = flip_frame_y(
|
||||
main_screen_height,
|
||||
visible_frame.size.height,
|
||||
visible_frame.origin.y,
|
||||
);
|
||||
visible_frame.origin.y = flipped_y;
|
||||
|
||||
return Ok(visible_frame);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
/// Move the frontmost window's origin to the point specified by `x` and `y`.
|
||||
pub fn move_frontmost_window(x: f64, y: f64) -> Result<(), Error> {
|
||||
let frontmost_window = get_frontmost_window()?;
|
||||
|
||||
let mut point = CGPoint::new(x, y);
|
||||
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
|
||||
let pos_attr = CFString::from_static_str("AXPosition");
|
||||
|
||||
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the frontmost window's frame to the specified frame - adjust size and
|
||||
/// location at the same time.
|
||||
pub fn set_frontmost_window_frame(frame: CGRect) -> Result<(), Error> {
|
||||
let frontmost_window = get_frontmost_window()?;
|
||||
|
||||
let mut point = frame.origin;
|
||||
let ptr_to_point = NonNull::new((&mut point as *mut CGPoint).cast::<c_void>()).unwrap();
|
||||
let pos_value = unsafe { AXValue::new(AXValueType::CGPoint, ptr_to_point) }.unwrap();
|
||||
let pos_attr = CFString::from_static_str("AXPosition");
|
||||
|
||||
let error = unsafe { frontmost_window.set_attribute_value(&pos_attr, pos_value.deref()) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
|
||||
let mut size = frame.size;
|
||||
let ptr_to_size = NonNull::new((&mut size as *mut CGSize).cast::<c_void>()).unwrap();
|
||||
let size_value = unsafe { AXValue::new(AXValueType::CGSize, ptr_to_size) }.unwrap();
|
||||
let size_attr = CFString::from_static_str("AXSize");
|
||||
|
||||
let error = unsafe { frontmost_window.set_attribute_value(&size_attr, size_value.deref()) };
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_fullscreen() -> Result<(), Error> {
|
||||
let frontmost_window = get_frontmost_window()?;
|
||||
let fullscreen_attr = CFString::from_static_str("AXFullScreen");
|
||||
|
||||
let mut current_value_ref: *const CFType = std::ptr::null();
|
||||
let error = unsafe {
|
||||
frontmost_window.copy_attribute_value(
|
||||
&fullscreen_attr,
|
||||
NonNull::new(&mut current_value_ref).unwrap(),
|
||||
)
|
||||
};
|
||||
|
||||
// TODO: If the attribute doesn't exist, error won't be Success as well.
|
||||
// Before we handle that, we need to know the error case that will be
|
||||
// returned in that case.
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
assert!(!current_value_ref.is_null());
|
||||
|
||||
let current_value = unsafe {
|
||||
let retained_boolean: CFRetained<CFBoolean> = CFRetained::from_raw(
|
||||
NonNull::new(current_value_ref.cast::<CFBoolean>().cast_mut()).unwrap(),
|
||||
);
|
||||
retained_boolean.as_bool()
|
||||
};
|
||||
|
||||
let new_value = !current_value;
|
||||
let new_value_ref: CFRetained<CFBoolean> = CFBoolean::new(new_value).retain();
|
||||
|
||||
let error =
|
||||
unsafe { frontmost_window.set_attribute_value(&fullscreen_attr, new_value_ref.deref()) };
|
||||
|
||||
if error != AXError::Success {
|
||||
return Err(Error::AXError(error));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
static LAST_FRAME: LazyLock<Mutex<HashMap<CGWindowID, CGRect>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
pub(crate) fn set_frontmost_window_last_frame(window_id: CGWindowID, frame: CGRect) {
|
||||
let mut map = LAST_FRAME.lock().unwrap();
|
||||
map.insert(window_id, frame);
|
||||
}
|
||||
|
||||
pub(crate) fn get_frontmost_window_last_frame(window_id: CGWindowID) -> Option<CGRect> {
|
||||
let map = LAST_FRAME.lock().unwrap();
|
||||
map.get(&window_id).cloned()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Private macOS APIs.
|
||||
|
||||
use bitflags::bitflags;
|
||||
use objc2_application_services::AXError;
|
||||
use objc2_application_services::AXUIElement;
|
||||
use objc2_core_foundation::CFArray;
|
||||
use objc2_core_graphics::CGError;
|
||||
use objc2_core_graphics::CGWindowID;
|
||||
use std::ffi::c_int;
|
||||
use std::ffi::c_uint;
|
||||
use std::ffi::c_ushort;
|
||||
|
||||
pub(crate) type CGSConnectionID = u32;
|
||||
pub(crate) type CGSSpaceID = c_int;
|
||||
|
||||
bitflags! {
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
#[repr(transparent)]
|
||||
pub struct CGSSpaceMask: c_int {
|
||||
const INCLUDE_CURRENT = 1 << 0;
|
||||
const INCLUDE_OTHERS = 1 << 1;
|
||||
|
||||
const INCLUDE_USER = 1 << 2;
|
||||
const INCLUDE_OS = 1 << 3;
|
||||
|
||||
const VISIBLE = 1 << 16;
|
||||
|
||||
const CURRENT_SPACES = Self::INCLUDE_USER.bits() | Self::INCLUDE_CURRENT.bits();
|
||||
const OTHER_SPACES = Self::INCLUDE_USER.bits() | Self::INCLUDE_OTHERS.bits();
|
||||
const ALL_SPACES =
|
||||
Self::INCLUDE_USER.bits() | Self::INCLUDE_OTHERS.bits() | Self::INCLUDE_CURRENT.bits();
|
||||
|
||||
const ALL_VISIBLE_SPACES = Self::ALL_SPACES.bits() | Self::VISIBLE.bits();
|
||||
|
||||
const CURRENT_OS_SPACES = Self::INCLUDE_OS.bits() | Self::INCLUDE_CURRENT.bits();
|
||||
const OTHER_OS_SPACES = Self::INCLUDE_OS.bits() | Self::INCLUDE_OTHERS.bits();
|
||||
const ALL_OS_SPACES =
|
||||
Self::INCLUDE_OS.bits() | Self::INCLUDE_OTHERS.bits() | Self::INCLUDE_CURRENT.bits();
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
/// Extract `window_id` from an AXUIElement.
|
||||
pub(crate) fn _AXUIElementGetWindow(
|
||||
elem: *mut AXUIElement,
|
||||
window_id: *mut CGWindowID,
|
||||
) -> AXError;
|
||||
|
||||
/// Connect to the WindowServer and get a connection descriptor.
|
||||
pub(crate) fn CGSMainConnectionID() -> CGSConnectionID;
|
||||
|
||||
/// It returns a CFArray of dictionaries. Each dictionary contains information
|
||||
/// about a display, including a list of all the spaces (CGSSpaceID) on that display.
|
||||
pub(crate) fn CGSCopyManagedDisplaySpaces(cid: CGSConnectionID) -> *mut CFArray;
|
||||
|
||||
/// Gets the ID of the space currently visible to the user.
|
||||
pub(crate) fn CGSGetActiveSpace(cid: CGSConnectionID) -> CGSSpaceID;
|
||||
|
||||
/// Returns the values the symbolic hot key represented by the given UID is configured with.
|
||||
pub(crate) fn CGSGetSymbolicHotKeyValue(
|
||||
hotKey: c_ushort,
|
||||
outKeyEquivalent: *mut c_ushort,
|
||||
outVirtualKeyCode: *mut c_ushort,
|
||||
outModifiers: *mut c_uint,
|
||||
) -> CGError;
|
||||
/// Returns whether the symbolic hot key represented by the given UID is enabled.
|
||||
pub(crate) fn CGSIsSymbolicHotKeyEnabled(hotKey: c_ushort) -> bool;
|
||||
/// Sets whether the symbolic hot key represented by the given UID is enabled.
|
||||
pub(crate) fn CGSSetSymbolicHotKeyEnabled(hotKey: c_ushort, isEnabled: bool) -> CGError;
|
||||
}
|
||||
25
src-tauri/src/extension/built_in/window_management/error.rs
Normal file
25
src-tauri/src/extension/built_in/window_management/error.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use objc2_application_services::AXError;
|
||||
use objc2_core_graphics::CGError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// Cannot find the focused window.
|
||||
#[error("Cannot find the focused window.")]
|
||||
CannotFindFocusWindow,
|
||||
/// Error code from the macOS Accessibility APIs.
|
||||
#[error("Error code from the macOS Accessibility APIs: {0:?}")]
|
||||
AXError(AXError),
|
||||
/// Function should be in called from the main thread, but it is not.
|
||||
#[error("Function should be in called from the main thread, but it is not.")]
|
||||
NotInMainThread,
|
||||
/// No monitor detected.
|
||||
#[error("No monitor detected.")]
|
||||
NoDisplay,
|
||||
/// Can only handle 16 Workspaces at most.
|
||||
#[error("libwmgr can only handle 16 Workspaces at most.")]
|
||||
TooManyWorkspace,
|
||||
/// Error code from the macOS Core Graphics APIs.
|
||||
#[error("Error code from the macOS Core Graphics APIs: {0:?}")]
|
||||
CGError(CGError),
|
||||
}
|
||||
973
src-tauri/src/extension/built_in/window_management/mod.rs
Normal file
973
src-tauri/src/extension/built_in/window_management/mod.rs
Normal file
@@ -0,0 +1,973 @@
|
||||
pub(crate) mod actions;
|
||||
mod backend;
|
||||
mod error;
|
||||
pub(crate) mod on_opened;
|
||||
pub(crate) mod search_source;
|
||||
|
||||
use crate::common::document::open;
|
||||
use crate::extension::Extension;
|
||||
use actions::Action;
|
||||
use backend::get_active_screen_visible_frame;
|
||||
use backend::get_frontmost_window_frame;
|
||||
use backend::get_frontmost_window_id;
|
||||
use backend::get_frontmost_window_last_frame;
|
||||
use backend::get_next_workspace_logical_id;
|
||||
use backend::get_previous_workspace_logical_id;
|
||||
use backend::list_visible_frame_of_all_screens;
|
||||
use backend::move_frontmost_window;
|
||||
use backend::move_frontmost_window_to_workspace;
|
||||
use backend::set_frontmost_window_frame;
|
||||
use backend::set_frontmost_window_last_frame;
|
||||
use backend::toggle_fullscreen;
|
||||
use error::Error;
|
||||
use objc2_core_foundation::{CGPoint, CGRect, CGSize};
|
||||
use oneshot::channel as oneshot_channel;
|
||||
use tauri::AppHandle;
|
||||
use tauri::async_runtime;
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
|
||||
pub(crate) const EXTENSION_ID: &str = "Window Management";
|
||||
|
||||
/// JSON file for this extension.
|
||||
pub(crate) const PLUGIN_JSON_FILE: &str = include_str!("./plugin.json");
|
||||
|
||||
pub(crate) fn perform_action_on_main_thread(
|
||||
tauri_app_handle: &AppHandle,
|
||||
action: Action,
|
||||
) -> Result<(), String> {
|
||||
let (tx, rx) = oneshot_channel();
|
||||
|
||||
tauri_app_handle
|
||||
.run_on_main_thread(move || {
|
||||
let res = perform_action(action).map_err(|e| e.to_string());
|
||||
tx.send(res)
|
||||
.expect("oneshot channel receiver unexpectedly dropped");
|
||||
})
|
||||
.expect("tauri internal bug, channel receiver dropped");
|
||||
|
||||
rx.recv()
|
||||
.expect("oneshot channel sender unexpectedly dropped before sending function return value")
|
||||
}
|
||||
|
||||
/// Perform this action to the focused window.
|
||||
fn perform_action(action: Action) -> Result<(), Error> {
|
||||
let visible_frame = get_active_screen_visible_frame()?;
|
||||
let frontmost_window_id = get_frontmost_window_id()?;
|
||||
let frontmost_window_frame = get_frontmost_window_frame()?;
|
||||
|
||||
set_frontmost_window_last_frame(frontmost_window_id, frontmost_window_frame);
|
||||
|
||||
match action {
|
||||
Action::TopHalf => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomHalf => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::LeftHalf => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 2.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::RightHalf => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 2.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::CenterHalf => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 2.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopLeftQuarter => {
|
||||
let origin = visible_frame.origin;
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 2.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopRightQuarter => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 2.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomLeftQuarter => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 2.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomRightQuarter => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 2.0,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 2.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopLeftSixth => {
|
||||
let origin = visible_frame.origin;
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopCenterSixth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopRightSixth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomLeftSixth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomCenterSixth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomRightSixth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 2.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height / 2.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopThird => {
|
||||
let origin = visible_frame.origin;
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 3.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::MiddleThird => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 3.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 3.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomThird => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height * 2.0 / 3.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 3.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::Center => {
|
||||
let window_size = frontmost_window_frame.size;
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + (visible_frame.size.width - window_size.width) / 2.0,
|
||||
y: visible_frame.origin.y + (visible_frame.size.height - window_size.height) / 2.0,
|
||||
};
|
||||
move_frontmost_window(origin.x, origin.y)
|
||||
}
|
||||
Action::FirstFourth => {
|
||||
let origin = visible_frame.origin;
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 4.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::SecondFourth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 4.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::ThirdFourth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 4.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 4.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::LastFourth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width * 3.0 / 4.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 4.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::FirstThird => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::CenterThird => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::LastThird => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width * 2.0 / 3.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width / 3.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::FirstTwoThirds => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width * 2.0 / 3.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::CenterTwoThirds => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 6.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width * 2.0 / 3.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::LastTwoThirds => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 3.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width * 2.0 / 3.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::FirstThreeFourths => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width * 3.0 / 4.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::CenterThreeFourths => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 8.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width * 3.0 / 4.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::LastThreeFourths => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 4.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width * 3.0 / 4.0,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopThreeFourths => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height * 3.0 / 4.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomThreeFourths => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 4.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height * 3.0 / 4.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopTwoThirds => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height * 2.0 / 3.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::BottomTwoThirds => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 3.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height * 2.0 / 3.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
|
||||
Action::TopCenterTwoThirds => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x + visible_frame.size.width / 6.0,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width * 2.0 / 3.0,
|
||||
height: visible_frame.size.height * 2.0 / 3.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopFirstFourth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 4.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopSecondFourth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height / 4.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 4.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopThirdFourth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height * 2.0 / 4.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 4.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::TopLastFourth => {
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: visible_frame.origin.y + visible_frame.size.height * 3.0 / 4.0,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: visible_frame.size.height / 4.0,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::MakeLarger => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let window_size = frontmost_window_frame.size;
|
||||
let delta_width = 20_f64;
|
||||
let delta_height = window_size.height / window_size.width * delta_width;
|
||||
let delta_origin_x = delta_width / 2.0;
|
||||
let delta_origin_y = delta_height / 2.0;
|
||||
|
||||
let new_width = {
|
||||
let possible_value = window_size.width + delta_width;
|
||||
if possible_value > visible_frame.size.width {
|
||||
visible_frame.size.width
|
||||
} else {
|
||||
possible_value
|
||||
}
|
||||
};
|
||||
let new_height = {
|
||||
let possible_value = window_size.height + delta_height;
|
||||
if possible_value > visible_frame.size.height {
|
||||
visible_frame.size.height
|
||||
} else {
|
||||
possible_value
|
||||
}
|
||||
};
|
||||
|
||||
let new_origin_x = {
|
||||
let possible_value = window_origin.x - delta_origin_x;
|
||||
if possible_value < visible_frame.origin.x {
|
||||
visible_frame.origin.x
|
||||
} else {
|
||||
possible_value
|
||||
}
|
||||
};
|
||||
let new_origin_y = {
|
||||
let possible_value = window_origin.y - delta_origin_y;
|
||||
if possible_value < visible_frame.origin.y {
|
||||
visible_frame.origin.y
|
||||
} else {
|
||||
possible_value
|
||||
}
|
||||
};
|
||||
|
||||
let origin = CGPoint {
|
||||
x: new_origin_x,
|
||||
y: new_origin_y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: new_width,
|
||||
height: new_height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::MakeSmaller => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let window_size = frontmost_window_frame.size;
|
||||
|
||||
let delta_width = 20_f64;
|
||||
let delta_height = window_size.height / window_size.width * delta_width;
|
||||
|
||||
let delta_origin_x = delta_width / 2.0;
|
||||
let delta_origin_y = delta_height / 2.0;
|
||||
|
||||
let origin = CGPoint {
|
||||
x: window_origin.x + delta_origin_x,
|
||||
y: window_origin.y + delta_origin_y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: window_size.width - delta_width,
|
||||
height: window_size.height - delta_height,
|
||||
};
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::AlmostMaximize => {
|
||||
let new_size = CGSize {
|
||||
width: visible_frame.size.width * 0.9,
|
||||
height: visible_frame.size.height * 0.9,
|
||||
};
|
||||
let new_origin = CGPoint {
|
||||
x: visible_frame.origin.x + (visible_frame.size.width * 0.1),
|
||||
y: visible_frame.origin.y + (visible_frame.size.height * 0.1),
|
||||
};
|
||||
let new_frame = CGRect {
|
||||
origin: new_origin,
|
||||
size: new_size,
|
||||
};
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::Maximize => {
|
||||
let new_frame = CGRect {
|
||||
origin: visible_frame.origin,
|
||||
size: visible_frame.size,
|
||||
};
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::MaximizeWidth => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let window_size = frontmost_window_frame.size;
|
||||
let origin = CGPoint {
|
||||
x: visible_frame.origin.x,
|
||||
y: window_origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: visible_frame.size.width,
|
||||
height: window_size.height,
|
||||
};
|
||||
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::MaximizeHeight => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let window_size = frontmost_window_frame.size;
|
||||
let origin = CGPoint {
|
||||
x: window_origin.x,
|
||||
y: visible_frame.origin.y,
|
||||
};
|
||||
let size = CGSize {
|
||||
width: window_size.width,
|
||||
height: visible_frame.size.height,
|
||||
};
|
||||
|
||||
let new_frame = CGRect { origin, size };
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::MoveUp => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let new_y = (window_origin.y - 10.0).max(visible_frame.origin.y);
|
||||
move_frontmost_window(window_origin.x, new_y)
|
||||
}
|
||||
Action::MoveDown => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let window_size = frontmost_window_frame.size;
|
||||
let new_y = (window_origin.y + 10.0)
|
||||
.min(visible_frame.origin.y + visible_frame.size.height - window_size.height);
|
||||
move_frontmost_window(window_origin.x, new_y)
|
||||
}
|
||||
Action::MoveLeft => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let new_x = (window_origin.x - 10.0).max(visible_frame.origin.x);
|
||||
move_frontmost_window(new_x, window_origin.y)
|
||||
}
|
||||
Action::MoveRight => {
|
||||
let window_origin = frontmost_window_frame.origin;
|
||||
let window_size = frontmost_window_frame.size;
|
||||
let new_x = (window_origin.x + 10.0)
|
||||
.min(visible_frame.origin.x + visible_frame.size.width - window_size.width);
|
||||
move_frontmost_window(new_x, window_origin.y)
|
||||
}
|
||||
Action::NextDesktop => {
|
||||
let Some(next_workspace_logical_id) = get_next_workspace_logical_id() else {
|
||||
// nothing to do
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
move_frontmost_window_to_workspace(next_workspace_logical_id)
|
||||
}
|
||||
Action::PreviousDesktop => {
|
||||
let Some(previous_workspace_logical_id) = get_previous_workspace_logical_id() else {
|
||||
// nothing to do
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Now let's switch the workspace
|
||||
move_frontmost_window_to_workspace(previous_workspace_logical_id)
|
||||
}
|
||||
Action::NextDisplay => {
|
||||
const TOO_MANY_MONITORS: &str = "I don't think you can have so many monitors";
|
||||
|
||||
let frames = list_visible_frame_of_all_screens()?;
|
||||
let n_frames = frames.len();
|
||||
if n_frames == 0 {
|
||||
return Err(Error::NoDisplay);
|
||||
}
|
||||
if n_frames == 1 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let index = frames
|
||||
.iter()
|
||||
.position(|fr| fr == &visible_frame)
|
||||
.expect("active screen should be in the list");
|
||||
let new_index: usize = {
|
||||
let index_i32: i32 = index.try_into().expect(TOO_MANY_MONITORS);
|
||||
let index_i32_plus_one = index_i32.checked_add(1).expect(TOO_MANY_MONITORS);
|
||||
let final_value = index_i32_plus_one % n_frames as i32;
|
||||
|
||||
final_value
|
||||
.try_into()
|
||||
.expect("final value should be positive")
|
||||
};
|
||||
|
||||
let new_frame = frames[new_index];
|
||||
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::PreviousDisplay => {
|
||||
const TOO_MANY_MONITORS: &str = "I don't think you can have so many monitors";
|
||||
|
||||
let frames = list_visible_frame_of_all_screens()?;
|
||||
let n_frames = frames.len();
|
||||
if n_frames == 0 {
|
||||
return Err(Error::NoDisplay);
|
||||
}
|
||||
if n_frames == 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let index = frames
|
||||
.iter()
|
||||
.position(|fr| fr == &visible_frame)
|
||||
.expect("active screen should be in the list");
|
||||
let new_index: usize = {
|
||||
let index_i32: i32 = index.try_into().expect(TOO_MANY_MONITORS);
|
||||
let index_i32_minus_one = index_i32 - 1;
|
||||
let n_frames_i32: i32 = n_frames.try_into().expect(TOO_MANY_MONITORS);
|
||||
let final_value = (index_i32_minus_one + n_frames_i32) % n_frames_i32;
|
||||
|
||||
final_value
|
||||
.try_into()
|
||||
.expect("final value should be positive")
|
||||
};
|
||||
|
||||
let new_frame = frames[new_index];
|
||||
|
||||
set_frontmost_window_frame(new_frame)
|
||||
}
|
||||
Action::Restore => {
|
||||
let Some(previous_frame) = get_frontmost_window_last_frame(frontmost_window_id) else {
|
||||
// Previous frame found, Nothing to do
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
set_frontmost_window_frame(previous_frame)
|
||||
}
|
||||
Action::ToggleFullscreen => toggle_fullscreen(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_up_commands_hotkeys(
|
||||
tauri_app_handle: &AppHandle,
|
||||
wm_extension: &Extension,
|
||||
) -> Result<(), String> {
|
||||
for command in wm_extension
|
||||
.commands
|
||||
.as_ref()
|
||||
.expect("Window Management extension has commands")
|
||||
.iter()
|
||||
.filter(|cmd| cmd.enabled)
|
||||
{
|
||||
if let Some(ref hotkey) = command.hotkey {
|
||||
let on_opened = on_opened::on_opened(&command.id);
|
||||
|
||||
let extension_id_clone = command.id.clone();
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey.as_str(), move |tauri_app_handle, _hotkey, event| {
|
||||
let on_opened_clone = on_opened.clone();
|
||||
let extension_id_clone = extension_id_clone.clone();
|
||||
let app_handle_clone = tauri_app_handle.clone();
|
||||
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
extension_id_clone,
|
||||
msg
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unset_commands_hotkeys(
|
||||
tauri_app_handle: &AppHandle,
|
||||
wm_extension: &Extension,
|
||||
) -> Result<(), String> {
|
||||
for command in wm_extension
|
||||
.commands
|
||||
.as_ref()
|
||||
.expect("Window Management extension has commands")
|
||||
.iter()
|
||||
.filter(|cmd| cmd.enabled)
|
||||
{
|
||||
if let Some(ref hotkey) = command.hotkey {
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.unregister(hotkey.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_up_command_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
wm_extension: &Extension,
|
||||
command_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let commands = wm_extension
|
||||
.commands
|
||||
.as_ref()
|
||||
.expect("Window Management has commands");
|
||||
let opt_command = commands.iter().find(|ext| ext.id == command_id);
|
||||
|
||||
let Some(command) = opt_command else {
|
||||
panic!("Window Management command does not exist {}", command_id);
|
||||
};
|
||||
|
||||
if let Some(ref hotkey) = command.hotkey {
|
||||
let on_opened = on_opened::on_opened(&command.id);
|
||||
|
||||
let extension_id_clone = command.id.clone();
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey.as_str(), move |tauri_app_handle, _hotkey, event| {
|
||||
let on_opened_clone = on_opened.clone();
|
||||
let extension_id_clone = extension_id_clone.clone();
|
||||
let app_handle_clone = tauri_app_handle.clone();
|
||||
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
extension_id_clone,
|
||||
msg
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unset_command_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
wm_extension: &Extension,
|
||||
command_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let commands = wm_extension
|
||||
.commands
|
||||
.as_ref()
|
||||
.expect("Window Management has commands");
|
||||
let opt_command = commands.iter().find(|ext| ext.id == command_id);
|
||||
|
||||
let Some(command) = opt_command else {
|
||||
panic!("Window Management command does not exist {}", command_id);
|
||||
};
|
||||
|
||||
if let Some(ref hotkey) = command.hotkey {
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.unregister(hotkey.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn register_command_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
command_id: &str,
|
||||
hotkey: &str,
|
||||
) -> Result<(), String> {
|
||||
let on_opened = on_opened::on_opened(&command_id);
|
||||
|
||||
let extension_id_clone = command_id.to_string();
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.on_shortcut(hotkey, move |tauri_app_handle, _hotkey, event| {
|
||||
let on_opened_clone = on_opened.clone();
|
||||
let extension_id_clone = extension_id_clone.clone();
|
||||
let app_handle_clone = tauri_app_handle.clone();
|
||||
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
async_runtime::spawn(async move {
|
||||
let result = open(app_handle_clone, on_opened_clone, None).await;
|
||||
if let Err(msg) = result {
|
||||
log::warn!(
|
||||
"failed to open extension [{}], error [{}]",
|
||||
extension_id_clone,
|
||||
msg
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn unregister_command_hotkey(
|
||||
tauri_app_handle: &AppHandle,
|
||||
wm_extension: &Extension,
|
||||
command_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let commands = wm_extension
|
||||
.commands
|
||||
.as_ref()
|
||||
.expect("Window Management has commands");
|
||||
let opt_command = commands.iter().find(|ext| ext.id == command_id);
|
||||
|
||||
let Some(command) = opt_command else {
|
||||
panic!("Window Management command does not exist {}", command_id);
|
||||
};
|
||||
|
||||
let Some(ref hotkey) = command.hotkey else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
tauri_app_handle
|
||||
.global_shortcut()
|
||||
.unregister(hotkey.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use super::actions::Action;
|
||||
use crate::common::document::OnOpened;
|
||||
use serde_plain;
|
||||
|
||||
pub(crate) fn on_opened(command_id: &str) -> OnOpened {
|
||||
let action: Action = serde_plain::from_str(command_id).unwrap_or_else(|_| {
|
||||
panic!("Window Management commands IDs should be valid for `enum Action`, someone corrupts the JSON file");
|
||||
});
|
||||
OnOpened::WindowManagementAction { action }
|
||||
}
|
||||
415
src-tauri/src/extension/built_in/window_management/plugin.json
Normal file
415
src-tauri/src/extension/built_in/window_management/plugin.json
Normal file
@@ -0,0 +1,415 @@
|
||||
{
|
||||
"id": "Window Management",
|
||||
"name": "Window Management",
|
||||
"platforms": [
|
||||
"macos"
|
||||
],
|
||||
"description": "Resize, reorganize and move your focused window effortlessly",
|
||||
"icon": "font_WindowManagement",
|
||||
"type": "extension",
|
||||
"category": "Utilities",
|
||||
"tags": [
|
||||
"Productivity"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "TopHalf",
|
||||
"name": "Top Half",
|
||||
"description": "Move the focused window to fill left half of the screen.",
|
||||
"icon": "font_TopHalf",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomHalf",
|
||||
"name": "Bottom Half",
|
||||
"description": "Move the focused window to fill bottom half of the screen.",
|
||||
"icon": "font_BottomHalf",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "LeftHalf",
|
||||
"name": "Left Half",
|
||||
"description": "Move the focused window to fill left half of the screen.",
|
||||
"icon": "font_LeftHalf",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "RightHalf",
|
||||
"name": "Right Half",
|
||||
"description": "Move the focused window to fill right half of the screen.",
|
||||
"icon": "font_RightHalf",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "CenterHalf",
|
||||
"name": "Center Half",
|
||||
"description": "Move the focused window to fill center half of the screen.",
|
||||
"icon": "font_CenterHalf",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "Maximize",
|
||||
"name": "Maximize",
|
||||
"description": "Maximize the focused window to fit the screen.",
|
||||
"icon": "font_Maximize",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopLeftQuarter",
|
||||
"name": "Top Left Quarter",
|
||||
"description": "Resize the focused window to the top left quarter of the screen.",
|
||||
"icon": "font_TopLeftQuarter",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopRightQuarter",
|
||||
"name": "Top Right Quarter",
|
||||
"description": "Resize the focused window to the top right quarter of the screen.",
|
||||
"icon": "font_TopRightQuarter",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomLeftQuarter",
|
||||
"name": "Bottom Left Quarter",
|
||||
"description": "Resize the focused window to the bottom left quarter of the screen.",
|
||||
"icon": "font_BottomLeftQuarter",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomRightQuarter",
|
||||
"name": "Bottom Right Quarter",
|
||||
"description": "Resize the focused window to the bottom right quarter of the screen.",
|
||||
"icon": "font_BottomRightQuarter",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopLeftSixth",
|
||||
"name": "Top Left Sixth",
|
||||
"description": "Resize the focused window to the top left sixth of the screen.",
|
||||
"icon": "font_TopLeftSixth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopCenterSixth",
|
||||
"name": "Top Center Sixth",
|
||||
"description": "Resize the focused window to the top center sixth of the screen.",
|
||||
"icon": "font_TopCenterSixth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopRightSixth",
|
||||
"name": "Top Right Sixth",
|
||||
"description": "Resize the focused window to the top right sixth of the screen.",
|
||||
"icon": "font_TopRightSixth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomLeftSixth",
|
||||
"name": "Bottom Left Sixth",
|
||||
"description": "Resize the focused window to the bottom left sixth of the screen.",
|
||||
"icon": "font_BottomLeftSixth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomCenterSixth",
|
||||
"name": "Bottom Center Sixth",
|
||||
"description": "Resize the focused window to the bottom center sixth of the screen.",
|
||||
"icon": "font_BottomCenterSixth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomRightSixth",
|
||||
"name": "Bottom Right Sixth",
|
||||
"description": "Resize the focused window to the bottom right sixth of the screen.",
|
||||
"icon": "font_BottomRightSixth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopThird",
|
||||
"name": "Top Third",
|
||||
"description": "Resize the focused window to the top third of the screen.",
|
||||
"icon": "font_TopThird",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MiddleThird",
|
||||
"name": "Middle Third",
|
||||
"description": "Resize the focused window to the middle third of the screen.",
|
||||
"icon": "font_MiddleThird",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomThird",
|
||||
"name": "Bottom Third",
|
||||
"description": "Resize the focused window to the bottom third of the screen.",
|
||||
"icon": "font_BottomThird",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "Center",
|
||||
"name": "Center",
|
||||
"description": "Center the focused window in the screen.",
|
||||
"icon": "font_Center",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "FirstFourth",
|
||||
"name": "First Fourth",
|
||||
"description": "Resize the focused window to the first fourth of the screen.",
|
||||
"icon": "font_FirstFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "SecondFourth",
|
||||
"name": "Second Fourth",
|
||||
"description": "Resize the focused window to the second fourth of the screen.",
|
||||
"icon": "font_SecondFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "ThirdFourth",
|
||||
"name": "Third Fourth",
|
||||
"description": "Resize the focused window to the third fourth of the screen.",
|
||||
"icon": "font_ThirdFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "LastFourth",
|
||||
"name": "Last Fourth",
|
||||
"description": "Resize the focused window to the last fourth of the screen.",
|
||||
"icon": "font_LastFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "FirstThird",
|
||||
"name": "First Third",
|
||||
"description": "Resize the focused window to the first third of the screen.",
|
||||
"icon": "font_FirstThird",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "CenterThird",
|
||||
"name": "Center Third",
|
||||
"description": "Resize the focused window to the center third of the screen.",
|
||||
"icon": "font_CenterThird",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "LastThird",
|
||||
"name": "Last Third",
|
||||
"description": "Resize the focused window to the last third of the screen.",
|
||||
"icon": "font_LastThird",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "FirstTwoThirds",
|
||||
"name": "First Two Thirds",
|
||||
"description": "Resize the focused window to the first two thirds of the screen.",
|
||||
"icon": "font_FirstTwoThirds",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "CenterTwoThirds",
|
||||
"name": "Center Two Thirds",
|
||||
"description": "Resize the focused window to the center two thirds of the screen.",
|
||||
"icon": "font_CenterTwoThirds",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "LastTwoThirds",
|
||||
"name": "Last Two Thirds",
|
||||
"description": "Resize the focused window to the last two thirds of the screen.",
|
||||
"icon": "font_LastTwoThirds",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "FirstThreeFourths",
|
||||
"name": "First Three Fourths",
|
||||
"description": "Resize the focused window to the first three fourths of the screen.",
|
||||
"icon": "font_FirstThreeFourths",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "CenterThreeFourths",
|
||||
"name": "Center Three Fourths",
|
||||
"description": "Resize the focused window to the center three fourths of the screen.",
|
||||
"icon": "font_CenterThreeFourths",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "LastThreeFourths",
|
||||
"name": "Last Three Fourths",
|
||||
"description": "Resize the focused window to the last three fourths of the screen.",
|
||||
"icon": "font_LastThreeFourths",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopThreeFourths",
|
||||
"name": "Top Three Fourths",
|
||||
"description": "Resize the focused window to the top three fourths of the screen.",
|
||||
"icon": "font_TopThreeFourths",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomThreeFourths",
|
||||
"name": "Bottom Three Fourths",
|
||||
"description": "Resize the focused window to the bottom three fourths of the screen.",
|
||||
"icon": "font_BottomThreeFourths",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopTwoThirds",
|
||||
"name": "Top Two Thirds",
|
||||
"description": "Resize the focused window to the top two thirds of the screen.",
|
||||
"icon": "font_TopTwoThirds",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "BottomTwoThirds",
|
||||
"name": "Bottom Two Thirds",
|
||||
"description": "Resize the focused window to the bottom two thirds of the screen.",
|
||||
"icon": "font_BottomTwoThirds",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopCenterTwoThirds",
|
||||
"name": "Top Center Two Thirds",
|
||||
"description": "Resize the focused window to the top center two thirds of the screen.",
|
||||
"icon": "font_TopCenterTwoThirds",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopFirstFourth",
|
||||
"name": "Top First Fourth",
|
||||
"description": "Resize the focused window to the top first fourth of the screen.",
|
||||
"icon": "font_TopFirstFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopSecondFourth",
|
||||
"name": "Top Second Fourth",
|
||||
"description": "Resize the focused window to the top second fourth of the screen.",
|
||||
"icon": "font_TopSecondFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopThirdFourth",
|
||||
"name": "Top Third Fourth",
|
||||
"description": "Resize the focused window to the top third fourth of the screen.",
|
||||
"icon": "font_TopThirdFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "TopLastFourth",
|
||||
"name": "Top Last Fourth",
|
||||
"description": "Resize the focused window to the top last fourth of the screen.",
|
||||
"icon": "font_TopLastFourth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MakeLarger",
|
||||
"name": "Make Larger",
|
||||
"description": "Increase the focused window until it reaches the screen size.",
|
||||
"icon": "font_MakeLarger",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MakeSmaller",
|
||||
"name": "Make Smaller",
|
||||
"description": "Decrease the focused window until it reaches its minimal size.",
|
||||
"icon": "font_MakeSmaller",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "AlmostMaximize",
|
||||
"name": "Almost Maximize",
|
||||
"description": "Maximize the focused window to almost fit the screen.",
|
||||
"icon": "font_AlmostMaximize",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MaximizeWidth",
|
||||
"name": "Maximize Width",
|
||||
"description": "Maximize width of the focused window to fit the screen.",
|
||||
"icon": "font_MaximizeWidth",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MaximizeHeight",
|
||||
"name": "Maximize Height",
|
||||
"description": "Maximize height of the focused window to fit the screen.",
|
||||
"icon": "font_MaximizeHeight",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MoveUp",
|
||||
"name": "Move Up",
|
||||
"description": "Move the focused window to the top edge of the screen.",
|
||||
"icon": "font_MoveUp",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MoveDown",
|
||||
"name": "Move Down",
|
||||
"description": "Move the focused window to the bottom of the screen.",
|
||||
"icon": "font_MoveDown",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MoveLeft",
|
||||
"name": "Move Left",
|
||||
"description": "Move the focused window to the left edge of the screen.",
|
||||
"icon": "font_MoveLeft",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "MoveRight",
|
||||
"name": "Move Right",
|
||||
"description": "Move the focused window to the right edge of the screen.",
|
||||
"icon": "font_MoveRight",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "NextDesktop",
|
||||
"name": "Next Desktop",
|
||||
"description": "Move the focused window to the next desktop.",
|
||||
"icon": "font_NextDesktop",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "PreviousDesktop",
|
||||
"name": "Previous Desktop",
|
||||
"description": "Move the focused window to the previous desktop.",
|
||||
"icon": "font_PreviousDesktop",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "NextDisplay",
|
||||
"name": "Next Display",
|
||||
"description": "Move the focused window to the next display.",
|
||||
"icon": "font_NextDisplay",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "PreviousDisplay",
|
||||
"name": "Previous Display",
|
||||
"description": "Move the focused window to the previous display.",
|
||||
"icon": "font_PreviousDisplay",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "Restore",
|
||||
"name": "Restore",
|
||||
"description": "Restore the focused window to its last position.",
|
||||
"icon": "font_Restore",
|
||||
"type": "command"
|
||||
},
|
||||
{
|
||||
"id": "ToggleFullscreen",
|
||||
"name": "Toggle Fullscreen",
|
||||
"description": "Toggle fullscreen mode.",
|
||||
"icon": "font_ToggleFullscreen",
|
||||
"type": "command"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use super::EXTENSION_ID;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::common::{
|
||||
error::SearchError,
|
||||
search::{QueryResponse, QuerySource, SearchQuery},
|
||||
traits::SearchSource,
|
||||
};
|
||||
use crate::extension::built_in::{get_built_in_extension_directory, load_extension_from_json_file};
|
||||
use crate::extension::{ExtensionType, LOCAL_QUERY_SOURCE_TYPE, calculate_text_similarity};
|
||||
use async_trait::async_trait;
|
||||
use hostname;
|
||||
use tauri::AppHandle;
|
||||
|
||||
/// A search source to allow users to search WM actions.
|
||||
pub(crate) struct WindowManagementSearchSource;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for WindowManagementSearchSource {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or(EXTENSION_ID.into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: EXTENSION_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(
|
||||
&self,
|
||||
tauri_app_handle: AppHandle,
|
||||
query: SearchQuery,
|
||||
) -> Result<QueryResponse, SearchError> {
|
||||
let Some(query_string) = query.query_strings.get("query") else {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
};
|
||||
let from = usize::try_from(query.from).expect("from too big");
|
||||
let size = usize::try_from(query.size).expect("size too big");
|
||||
|
||||
let query_string = query_string.trim();
|
||||
if query_string.is_empty() {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
}
|
||||
let query_string_lowercase = query_string.to_lowercase();
|
||||
|
||||
let extension = load_extension_from_json_file(
|
||||
&get_built_in_extension_directory(&tauri_app_handle),
|
||||
super::EXTENSION_ID,
|
||||
)
|
||||
.map_err(SearchError::InternalError)?;
|
||||
let commands = extension.commands.expect("this extension has commands");
|
||||
|
||||
let mut hits: Vec<(Document, f64)> = Vec::new();
|
||||
|
||||
// We know they are all commands
|
||||
let command_type_string = ExtensionType::Command.to_string();
|
||||
for command in commands.iter().filter(|ext| ext.enabled) {
|
||||
let score = {
|
||||
let mut score = 0_f64;
|
||||
|
||||
if let Some(name_score) =
|
||||
calculate_text_similarity(&query_string_lowercase, &command.name.to_lowercase())
|
||||
{
|
||||
score += name_score;
|
||||
}
|
||||
|
||||
if let Some(ref alias) = command.alias {
|
||||
if let Some(alias_score) =
|
||||
calculate_text_similarity(&query_string_lowercase, &alias.to_lowercase())
|
||||
{
|
||||
score += alias_score;
|
||||
}
|
||||
}
|
||||
|
||||
score
|
||||
};
|
||||
|
||||
if score > 0.0 {
|
||||
let on_opened = super::on_opened::on_opened(&command.id);
|
||||
let url = on_opened.url();
|
||||
|
||||
let document = Document {
|
||||
id: command.id.clone(),
|
||||
title: Some(command.name.clone()),
|
||||
icon: Some(command.icon.clone()),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(url),
|
||||
category: Some(command_type_string.clone()),
|
||||
source: Some(DataSourceReference {
|
||||
id: Some(command_type_string.clone()),
|
||||
name: Some(command_type_string.clone()),
|
||||
icon: None,
|
||||
r#type: Some(command_type_string.clone()),
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
hits.push((document, score));
|
||||
}
|
||||
}
|
||||
|
||||
hits.sort_by(|(_, score_a), (_, score_b)| {
|
||||
score_a
|
||||
.partial_cmp(&score_b)
|
||||
.expect("expect no NAN/INFINITY/...")
|
||||
});
|
||||
|
||||
let total_hits = hits.len();
|
||||
let from_size_applied = hits.into_iter().skip(from).take(size).collect();
|
||||
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: from_size_applied,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -773,7 +773,7 @@ pub(crate) async fn set_extension_alias(
|
||||
let bundle_id_borrowed = bundle_id.borrow();
|
||||
|
||||
if built_in::is_extension_built_in(&bundle_id_borrowed) {
|
||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias);
|
||||
built_in::set_built_in_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias)?;
|
||||
return Ok(());
|
||||
}
|
||||
third_party::THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE.get().expect("global third party search source not set, looks like init_extensions() has not been executed").set_extension_alias(&tauri_app_handle, &bundle_id_borrowed, &alias).await
|
||||
@@ -1111,6 +1111,52 @@ pub(crate) struct ExtensionSettings {
|
||||
pub(crate) hide_before_open: Option<bool>,
|
||||
}
|
||||
|
||||
/// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||
/// Assumes query and text are already lowercased.
|
||||
///
|
||||
/// Used in extension_to_hit().
|
||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||
if query.is_empty() || text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if text == query {
|
||||
return Some(1.0); // Perfect match
|
||||
}
|
||||
|
||||
let query_len = query.len() as f64;
|
||||
let text_len = text.len() as f64;
|
||||
let ratio = query_len / text_len;
|
||||
let mut score: f64 = 0.0;
|
||||
|
||||
// Case 1: Text starts with the query (prefix match)
|
||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||
if text.starts_with(query) {
|
||||
score = score.max(0.5 + 0.4 * ratio);
|
||||
}
|
||||
|
||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||
if text.contains(query) {
|
||||
score = score.max(0.3 + 0.3 * ratio);
|
||||
}
|
||||
|
||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||
if score < 0.2 {
|
||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||
Some(score.min(0.95))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1581,4 +1627,96 @@ mod tests {
|
||||
let result = link.concatenate_url(&None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// Helper function for approximate floating point comparison
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||
assert_eq!(calculate_text_similarity("", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_perfect_match() {
|
||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_match() {
|
||||
// For "te" and "text":
|
||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||
let score = calculate_text_similarity("te", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.7));
|
||||
|
||||
// For "tex" and "text":
|
||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_match() {
|
||||
// For "ex" and "text":
|
||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.45));
|
||||
|
||||
// Prefix should score higher than substring
|
||||
assert!(
|
||||
calculate_text_similarity("te", "text").unwrap()
|
||||
> calculate_text_similarity("ex", "text").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_presence() {
|
||||
// Characters present but not in sequence
|
||||
// "tac" in "contact" - not a substring, but all chars exist
|
||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||
|
||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||
|
||||
// Should not apply if some characters are missing
|
||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_scenarios() {
|
||||
// Test that character presence fallback doesn't override higher scores
|
||||
// "tex" is a prefix of "text" with score 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
|
||||
// Test a case where the characters exist but it's already a substring
|
||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_similarity() {
|
||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_capping() {
|
||||
// Use a long query that's a prefix of a slightly longer text
|
||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||
|
||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
|
||||
// Verify that non-perfect matches are capped at 0.95
|
||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
170
src-tauri/src/extension/third_party/mod.rs
vendored
170
src-tauri/src/extension/third_party/mod.rs
vendored
@@ -15,6 +15,7 @@ use crate::common::search::QuerySource;
|
||||
use crate::common::search::SearchQuery;
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::extension::ExtensionBundleIdBorrowed;
|
||||
use crate::extension::calculate_text_similarity;
|
||||
use crate::util::platform::Platform;
|
||||
use async_trait::async_trait;
|
||||
use borrowme::ToOwned;
|
||||
@@ -803,7 +804,20 @@ impl SearchSource for ThirdPartyExtensionsSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_to_hit(
|
||||
#[tauri::command]
|
||||
pub(crate) async fn uninstall_extension(
|
||||
tauri_app_handle: AppHandle,
|
||||
developer: String,
|
||||
extension_id: String,
|
||||
) -> Result<(), String> {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party search source not set")
|
||||
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn extension_to_hit(
|
||||
extension: &Extension,
|
||||
query_lower: &str,
|
||||
opt_data_source: Option<&str>,
|
||||
@@ -872,157 +886,3 @@ fn extension_to_hit(
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates a similarity score between a query and a text, aiming for a [0, 1] range.
|
||||
// Assumes query and text are already lowercased.
|
||||
fn calculate_text_similarity(query: &str, text: &str) -> Option<f64> {
|
||||
if query.is_empty() || text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if text == query {
|
||||
return Some(1.0); // Perfect match
|
||||
}
|
||||
|
||||
let query_len = query.len() as f64;
|
||||
let text_len = text.len() as f64;
|
||||
let ratio = query_len / text_len;
|
||||
let mut score: f64 = 0.0;
|
||||
|
||||
// Case 1: Text starts with the query (prefix match)
|
||||
// Score: base 0.5, bonus up to 0.4 for how much of `text` is covered by `query`. Max 0.9.
|
||||
if text.starts_with(query) {
|
||||
score = score.max(0.5 + 0.4 * ratio);
|
||||
}
|
||||
|
||||
// Case 2: Text contains the query (substring match, not necessarily prefix)
|
||||
// Score: base 0.3, bonus up to 0.3. Max 0.6.
|
||||
// `score.max` ensures that if it's both a prefix and contains, the higher score (prefix) is taken.
|
||||
if text.contains(query) {
|
||||
score = score.max(0.3 + 0.3 * ratio);
|
||||
}
|
||||
|
||||
// Case 3: Fallback for "all query characters exist in text" (order-independent)
|
||||
if score < 0.2 {
|
||||
if query.chars().all(|c_q| text.contains(c_q)) {
|
||||
score = score.max(0.15); // Fixed low score for this weaker match type
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
// Cap non-perfect matches slightly below 1.0 to make perfect (1.0) distinct.
|
||||
Some(score.min(0.95))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn uninstall_extension(
|
||||
tauri_app_handle: AppHandle,
|
||||
developer: String,
|
||||
extension_id: String,
|
||||
) -> Result<(), String> {
|
||||
THIRD_PARTY_EXTENSIONS_SEARCH_SOURCE
|
||||
.get()
|
||||
.expect("global third party search source not set")
|
||||
.uninstall_extension(&tauri_app_handle, &developer, &extension_id)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Helper function for approximate floating point comparison
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-10
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_strings() {
|
||||
assert_eq!(calculate_text_similarity("", "text"), None);
|
||||
assert_eq!(calculate_text_similarity("query", ""), None);
|
||||
assert_eq!(calculate_text_similarity("", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_perfect_match() {
|
||||
assert_eq!(calculate_text_similarity("text", "text"), Some(1.0));
|
||||
assert_eq!(calculate_text_similarity("a", "a"), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_match() {
|
||||
// For "te" and "text":
|
||||
// score = 0.5 + 0.4 * (2/4) = 0.5 + 0.2 = 0.7
|
||||
let score = calculate_text_similarity("te", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.7));
|
||||
|
||||
// For "tex" and "text":
|
||||
// score = 0.5 + 0.4 * (3/4) = 0.5 + 0.3 = 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_substring_match() {
|
||||
// For "ex" and "text":
|
||||
// score = 0.3 + 0.3 * (2/4) = 0.3 + 0.15 = 0.45
|
||||
let score = calculate_text_similarity("ex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.45));
|
||||
|
||||
// Prefix should score higher than substring
|
||||
assert!(
|
||||
calculate_text_similarity("te", "text").unwrap()
|
||||
> calculate_text_similarity("ex", "text").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_character_presence() {
|
||||
// Characters present but not in sequence
|
||||
// "tac" in "contact" - not a substring, but all chars exist
|
||||
let score = calculate_text_similarity("tac", "contact").unwrap();
|
||||
assert!(approx_eq(0.3 + 0.3 * (3.0 / 7.0), score));
|
||||
|
||||
assert!(calculate_text_similarity("ac", "contact").is_some());
|
||||
|
||||
// Should not apply if some characters are missing
|
||||
assert_eq!(calculate_text_similarity("xyz", "contact"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_scenarios() {
|
||||
// Test that character presence fallback doesn't override higher scores
|
||||
// "tex" is a prefix of "text" with score 0.8
|
||||
let score = calculate_text_similarity("tex", "text").unwrap();
|
||||
assert!(approx_eq(score, 0.8));
|
||||
|
||||
// Test a case where the characters exist but it's already a substring
|
||||
// "act" is a substring of "contact" with score > 0.2, so fallback won't apply
|
||||
let expected_score = 0.3 + 0.3 * (3.0 / 7.0);
|
||||
let actual_score = calculate_text_similarity("act", "contact").unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_similarity() {
|
||||
assert_eq!(calculate_text_similarity("xyz", "test"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_capping() {
|
||||
// Use a long query that's a prefix of a slightly longer text
|
||||
let long_text = "abcdefghijklmnopqrstuvwxyz";
|
||||
let long_prefix = "abcdefghijklmnopqrstuvwxy"; // All but last letter
|
||||
|
||||
// Expected score would be 0.5 + 0.4 * (25/26) = 0.5 + 0.385 = 0.885
|
||||
let expected_score = 0.5 + 0.4 * (25.0 / 26.0);
|
||||
let actual_score = calculate_text_similarity(long_prefix, long_text).unwrap();
|
||||
assert!(approx_eq(actual_score, expected_score));
|
||||
|
||||
// Verify that non-perfect matches are capped at 0.95
|
||||
assert!(calculate_text_similarity("almost", "almost perfect").unwrap() <= 0.95);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user