├── .github └── workflows │ └── lint.yaml ├── .gitignore ├── Cargo.toml ├── README.md └── src ├── lib.rs ├── linux.rs ├── macos.rs ├── utils.rs └── windows.rs /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | clippy: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | pull-requests: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: dtolnay/rust-toolchain@nightly 19 | with: 20 | components: clippy 21 | - run: mkdir -p ./dist/tauri 22 | - uses: giraffate/clippy-action@v1 23 | with: 24 | reporter: 'github-pr-review' 25 | github_token: ${{ secrets.GITHUB_TOKEN }} 26 | clippy_flags: -- -Dwarning 27 | 28 | rustfmt: 29 | runs-on: ubuntu-latest 30 | permissions: 31 | pull-requests: write 32 | checks: write 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: dtolnay/rust-toolchain@nightly 36 | with: 37 | components: rustfmt 38 | - uses: LoliGothick/rustfmt-check@master 39 | with: 40 | token: ${{ secrets.GITHUB_TOKEN }} 41 | flags: --all 42 | 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 4 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 5 | Cargo.lock -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "get-selected-text" 3 | version = "0.1.6" 4 | edition = "2021" 5 | authors = ["yetone "] 6 | license = "MIT / Apache-2.0" 7 | homepage = "https://github.com/yetone/get-selected-text" 8 | repository = "https://github.com/yetone/get-selected-text" 9 | description = "A tiny Rust library that allows you to easily obtain selected text across all platforms (macOS, Windows, Linux)" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [target.'cfg(not(target_os = "macos"))'.dependencies] 14 | arboard = "3.2.0" 15 | enigo = { version = "0.2.0", features = [ "xdo" ] } 16 | 17 | [target.'cfg(target_os = "macos")'.dependencies] 18 | cocoa = "0.24" 19 | objc = "0.2.7" 20 | macos-accessibility-client = "0.0.1" 21 | core-foundation = "0.9.3" 22 | core-graphics = "0.22.3" 23 | accessibility-ng = "0.1.6" 24 | accessibility-sys-ng = "0.1.3" 25 | 26 | [dependencies] 27 | active-win-pos-rs = "0.8.3" 28 | debug_print = "1.0.0" 29 | lru = "0.12.3" 30 | parking_lot = "0.12.1" 31 | 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | get-selected-text 2 | ================= 3 | 4 | [![Lint](https://github.com/yetone/get-selected-text/actions/workflows/lint.yaml/badge.svg)](https://github.com/yetone/get-selected-text/actions/workflows/lint.yaml) [![crates.io](https://img.shields.io/crates/v/get-selected-text.svg)](https://crates.io/crates/get-selected-text) 5 | 6 | A tiny Rust library that allows you to easily obtain selected text across all platforms (macOS, Windows, Linux). 7 | 8 | Corresponding Node.js package: [node-get-selected-text](https://github.com/yetone/node-get-selected-text) 9 | 10 | ## Usage 11 | 12 | ### Add: 13 | 14 | ```bash 15 | cargo add get-selected-text 16 | ``` 17 | 18 | ### Use: 19 | 20 | ```rust 21 | use get_selected_text::get_selected_text; 22 | 23 | fn main() { 24 | match get_selected_text() { 25 | Ok(selected_text) => { 26 | println!("selected text: {}", selected_text); 27 | }, 28 | Err(()) => { 29 | println!("error occurred while getting the selected text"); 30 | } 31 | } 32 | } 33 | ``` 34 | 35 | ## How does it work? 36 | 37 | ### macOS 38 | 39 | Prioritize using the A11y API to obtain selected text. If the application does not comply with the A11y API, simulate pressing cmd+c to borrow from the clipboard to get the selected text. 40 | 41 | To avoid annoying Alert sounds when simulating pressing cmd+c, it will automatically mute the Alert sound (Only the Alert sound is muted, it won't affect the volume of listening to music and watching videos). The volume of the Alert sound will be restored after releasing the key. 42 | 43 | Therefore, on macOS, you need to grant accessbility permissions in advance. The sample code is as follows: 44 | 45 | ```rust 46 | fn query_accessibility_permissions() -> bool { 47 | let trusted = macos_accessibility_client::accessibility::application_is_trusted_with_prompt(); 48 | if trusted { 49 | print!("Application is totally trusted!"); 50 | } else { 51 | print!("Application isn't trusted :("); 52 | } 53 | trusted 54 | } 55 | ``` 56 | 57 | ### Windows + Linux 58 | 59 | Simulate pressing ctrl+c to use the clipboard to obtain the selected text. 60 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(target_os = "macos"))] 2 | mod utils; 3 | 4 | #[cfg(target_os = "linux")] 5 | mod linux; 6 | #[cfg(target_os = "macos")] 7 | mod macos; 8 | #[cfg(target_os = "windows")] 9 | mod windows; 10 | 11 | #[cfg(target_os = "linux")] 12 | use crate::linux::get_selected_text as _get_selected_text; 13 | #[cfg(target_os = "macos")] 14 | use crate::macos::get_selected_text as _get_selected_text; 15 | #[cfg(target_os = "windows")] 16 | use crate::windows::get_selected_text as _get_selected_text; 17 | 18 | /// # Example 19 | /// 20 | /// ``` 21 | /// use get_selected_text::get_selected_text; 22 | /// 23 | /// let text = get_selected_text().unwrap(); 24 | /// println!("{}", text); 25 | /// ``` 26 | pub fn get_selected_text() -> Result> { 27 | _get_selected_text() 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use super::*; 33 | 34 | #[test] 35 | fn test_get_selected_text() { 36 | println!("--- get_selected_text ---"); 37 | let text = get_selected_text().unwrap(); 38 | println!("selected text: {:#?}", text); 39 | println!("--- get_selected_text ---"); 40 | let text = get_selected_text().unwrap(); 41 | println!("selected text: {:#?}", text); 42 | println!("--- get_selected_text ---"); 43 | let text = get_selected_text().unwrap(); 44 | println!("selected text: {:#?}", text); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/linux.rs: -------------------------------------------------------------------------------- 1 | use enigo::*; 2 | 3 | pub fn get_selected_text() -> Result> { 4 | let mut enigo = Enigo::new(&Settings::default()).unwrap(); 5 | crate::utils::get_selected_text_by_clipboard(&mut enigo, false) 6 | } 7 | -------------------------------------------------------------------------------- /src/macos.rs: -------------------------------------------------------------------------------- 1 | use std::num::NonZeroUsize; 2 | 3 | use accessibility_ng::{AXAttribute, AXUIElement}; 4 | use accessibility_sys_ng::{kAXFocusedUIElementAttribute, kAXSelectedTextAttribute}; 5 | use active_win_pos_rs::get_active_window; 6 | use core_foundation::string::CFString; 7 | use debug_print::debug_println; 8 | use lru::LruCache; 9 | use parking_lot::Mutex; 10 | 11 | static GET_SELECTED_TEXT_METHOD: Mutex>> = Mutex::new(None); 12 | 13 | pub fn get_selected_text() -> Result> { 14 | if GET_SELECTED_TEXT_METHOD.lock().is_none() { 15 | let cache = LruCache::new(NonZeroUsize::new(100).unwrap()); 16 | *GET_SELECTED_TEXT_METHOD.lock() = Some(cache); 17 | } 18 | let mut cache = GET_SELECTED_TEXT_METHOD.lock(); 19 | let cache = cache.as_mut().unwrap(); 20 | let app_name = match get_active_window() { 21 | Ok(window) => window.app_name, 22 | Err(_) => return Err("No active window found".into()), 23 | }; 24 | // debug_println!("app_name: {}", app_name); 25 | if let Some(text) = cache.get(&app_name) { 26 | if *text == 0 { 27 | return get_selected_text_by_ax(); 28 | } 29 | return get_selected_text_by_clipboard_using_applescript(); 30 | } 31 | match get_selected_text_by_ax() { 32 | Ok(text) => { 33 | if !text.is_empty() { 34 | cache.put(app_name, 0); 35 | } 36 | Ok(text) 37 | } 38 | Err(_) => match get_selected_text_by_clipboard_using_applescript() { 39 | Ok(text) => { 40 | if !text.is_empty() { 41 | cache.put(app_name, 1); 42 | } 43 | Ok(text) 44 | } 45 | Err(e) => Err(e), 46 | }, 47 | } 48 | } 49 | 50 | fn get_selected_text_by_ax() -> Result> { 51 | // debug_println!("get_selected_text_by_ax"); 52 | let system_element = AXUIElement::system_wide(); 53 | let Some(selected_element) = system_element 54 | .attribute(&AXAttribute::new(&CFString::from_static_string( 55 | kAXFocusedUIElementAttribute, 56 | ))) 57 | .map(|element| element.downcast_into::()) 58 | .ok() 59 | .flatten() 60 | else { 61 | return Err(Box::new(std::io::Error::new( 62 | std::io::ErrorKind::NotFound, 63 | "No selected element", 64 | ))); 65 | }; 66 | let Some(selected_text) = selected_element 67 | .attribute(&AXAttribute::new(&CFString::from_static_string( 68 | kAXSelectedTextAttribute, 69 | ))) 70 | .map(|text| text.downcast_into::()) 71 | .ok() 72 | .flatten() 73 | else { 74 | return Err(Box::new(std::io::Error::new( 75 | std::io::ErrorKind::NotFound, 76 | "No selected text", 77 | ))); 78 | }; 79 | Ok(selected_text.to_string()) 80 | } 81 | 82 | const APPLE_SCRIPT: &str = r#" 83 | use AppleScript version "2.4" 84 | use scripting additions 85 | use framework "Foundation" 86 | use framework "AppKit" 87 | 88 | set savedAlertVolume to alert volume of (get volume settings) 89 | 90 | -- Back up clipboard contents: 91 | set savedClipboard to the clipboard 92 | 93 | set thePasteboard to current application's NSPasteboard's generalPasteboard() 94 | set theCount to thePasteboard's changeCount() 95 | 96 | tell application "System Events" 97 | set volume alert volume 0 98 | end tell 99 | 100 | -- Copy selected text to clipboard: 101 | tell application "System Events" to keystroke "c" using {command down} 102 | delay 0.1 -- Without this, the clipboard may have stale data. 103 | 104 | tell application "System Events" 105 | set volume alert volume savedAlertVolume 106 | end tell 107 | 108 | if thePasteboard's changeCount() is theCount then 109 | return "" 110 | end if 111 | 112 | set theSelectedText to the clipboard 113 | 114 | set the clipboard to savedClipboard 115 | 116 | theSelectedText 117 | "#; 118 | 119 | fn get_selected_text_by_clipboard_using_applescript() -> Result> 120 | { 121 | // debug_println!("get_selected_text_by_clipboard_using_applescript"); 122 | let output = std::process::Command::new("osascript") 123 | .arg("-e") 124 | .arg(APPLE_SCRIPT) 125 | .output()?; 126 | if output.status.success() { 127 | let content = String::from_utf8(output.stdout)?; 128 | let content = content.trim(); 129 | Ok(content.to_string()) 130 | } else { 131 | let err = output 132 | .stderr 133 | .into_iter() 134 | .map(|c| c as char) 135 | .collect::() 136 | .into(); 137 | Err(err) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use enigo::*; 2 | use parking_lot::Mutex; 3 | use std::{thread, time::Duration}; 4 | 5 | static COPY_PASTE_LOCKER: Mutex<()> = Mutex::new(()); 6 | static INPUT_LOCK_LOCKER: Mutex<()> = Mutex::new(()); 7 | 8 | pub(crate) fn right_arrow_click(enigo: &mut Enigo, n: usize) { 9 | let _guard = INPUT_LOCK_LOCKER.lock(); 10 | 11 | for _ in 0..n { 12 | enigo.key(Key::RightArrow, Direction::Click).unwrap(); 13 | } 14 | } 15 | 16 | pub(crate) fn up_control_keys(enigo: &mut Enigo) { 17 | enigo.key(Key::Control, Direction::Release).unwrap(); 18 | enigo.key(Key::Alt, Direction::Release).unwrap(); 19 | enigo.key(Key::Shift, Direction::Release).unwrap(); 20 | enigo.key(Key::Space, Direction::Release).unwrap(); 21 | enigo.key(Key::Tab, Direction::Release).unwrap(); 22 | } 23 | 24 | pub(crate) fn copy(enigo: &mut Enigo) { 25 | let _guard = COPY_PASTE_LOCKER.lock(); 26 | 27 | crate::utils::up_control_keys(enigo); 28 | 29 | enigo.key(Key::Control, Direction::Press).unwrap(); 30 | #[cfg(target_os = "windows")] 31 | enigo.key(Key::C, Direction::Click).unwrap(); 32 | #[cfg(target_os = "linux")] 33 | enigo.key(Key::Unicode('c'), Direction::Click).unwrap(); 34 | enigo.key(Key::Control, Direction::Release).unwrap(); 35 | } 36 | 37 | pub(crate) fn get_selected_text_by_clipboard( 38 | enigo: &mut Enigo, 39 | cancel_select: bool, 40 | ) -> Result> { 41 | use arboard::Clipboard; 42 | 43 | let old_clipboard = (Clipboard::new()?.get_text(), Clipboard::new()?.get_image()); 44 | 45 | let mut write_clipboard = Clipboard::new()?; 46 | 47 | let not_selected_placeholder = ""; 48 | 49 | write_clipboard.set_text(not_selected_placeholder)?; 50 | 51 | thread::sleep(Duration::from_millis(50)); 52 | 53 | copy(enigo); 54 | 55 | if cancel_select { 56 | crate::utils::right_arrow_click(enigo, 1); 57 | } 58 | 59 | thread::sleep(Duration::from_millis(100)); 60 | 61 | let new_text = Clipboard::new()?.get_text(); 62 | 63 | match old_clipboard { 64 | (Ok(old_text), _) => { 65 | // Old Content is Text 66 | write_clipboard.set_text(old_text.clone())?; 67 | if let Ok(new) = new_text { 68 | if new.trim() == not_selected_placeholder.trim() { 69 | Ok(String::new()) 70 | } else { 71 | Ok(new) 72 | } 73 | } else { 74 | Ok(String::new()) 75 | } 76 | } 77 | (_, Ok(image)) => { 78 | // Old Content is Image 79 | write_clipboard.set_image(image)?; 80 | if let Ok(new) = new_text { 81 | if new.trim() == not_selected_placeholder.trim() { 82 | Ok(String::new()) 83 | } else { 84 | Ok(new) 85 | } 86 | } else { 87 | Ok(String::new()) 88 | } 89 | } 90 | _ => { 91 | // Old Content is Empty 92 | write_clipboard.clear()?; 93 | if let Ok(new) = new_text { 94 | if new.trim() == not_selected_placeholder.trim() { 95 | Ok(String::new()) 96 | } else { 97 | Ok(new) 98 | } 99 | } else { 100 | Ok(String::new()) 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/windows.rs: -------------------------------------------------------------------------------- 1 | use enigo::*; 2 | 3 | pub fn get_selected_text() -> Result> { 4 | let mut enigo = Enigo::new(&Settings::default()).unwrap(); 5 | crate::utils::get_selected_text_by_clipboard(&mut enigo, false) 6 | } 7 | --------------------------------------------------------------------------------