Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ objc2 = "0.6"
objc2-foundation = "0.3"
objc2-app-kit = "0.3"
objc2-core-graphics = "0.3"
objc2-core-foundation = "0.3"
objc2-application-services = "0.3"
objc2-vision = "0.3"
objc2-av-foundation = { version = "0.3", features = ["objc2-core-media", "AVAssetTrack"] }
Expand Down
54 changes: 54 additions & 0 deletions crates/cp-core/src/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ impl Catalog {
Take::Presence
}

pub fn decide_in(&self, family: Option<Family>, id: &str) -> Take {
let id = self.canonical(id);
if family == Some(Family::Files) && self.images_by_preference.contains(&id) {
return Take::Presence;
}
self.decide(id)
}

pub fn refusal(&self, offered: &[&str]) -> Option<Refusal> {
offered.iter().find_map(|id| {
self.concealed
Expand Down Expand Up @@ -269,6 +277,52 @@ mod tests {
Some(Family::Image)
);
}

#[test]
fn a_copied_file_keeps_its_path_and_notes_its_icon() {
let files = Some(Family::Files);
assert_eq!(PROBE.decide_in(files, "cheap/image"), Take::Presence);
assert_eq!(PROBE.decide_in(files, "costly/image"), Take::Presence);
assert_eq!(PROBE.decide_in(files, "one/file"), Take::Payload);
assert_eq!(PROBE.decide_in(files, "old/text"), Take::Payload);
assert_eq!(PROBE.decide_in(files, "hangs/forever"), Take::Never);
}

#[test]
fn outside_a_file_copy_the_family_changes_nothing() {
for family in [None, Some(Family::Text), Some(Family::Image)] {
for id in [
"cheap/image",
"costly/image",
"old/text",
"hangs/forever",
"huge/icon",
"who/knows",
] {
assert_eq!(
PROBE.decide_in(family, id),
PROBE.decide(id),
"{family:?} {id}"
);
}
}
}

#[test]
fn a_file_copy_notes_the_icon_under_its_legacy_name_too() {
const ICONIC: Catalog = Catalog {
aliases: &[("legacy/picture", "cheap/image")],
..PROBE
};
assert_eq!(
ICONIC.decide_in(Some(Family::Files), "legacy/picture"),
Take::Presence
);
assert_eq!(
ICONIC.decide_in(Some(Family::Text), "legacy/picture"),
Take::Payload
);
}
}

#[cfg(test)]
Expand Down
25 changes: 25 additions & 0 deletions crates/cp-core/src/paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ pub enum Next {
Degrade,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Route {
Keystroke,
Menu,
}

pub fn route_for(can_post_events: bool, can_drive_menus: bool) -> Option<Route> {
if can_post_events {
return Some(Route::Keystroke);
}
can_drive_menus.then_some(Route::Menu)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Warning {
SecureInputActive,
Expand Down Expand Up @@ -99,6 +112,18 @@ impl Failure {
mod tests {
use super::*;

#[test]
fn the_keystroke_is_the_route_whenever_it_is_allowed() {
assert_eq!(route_for(true, false), Some(Route::Keystroke));
assert_eq!(route_for(true, true), Some(Route::Keystroke));
}

#[test]
fn the_menu_is_the_route_only_when_the_keystroke_is_denied() {
assert_eq!(route_for(false, true), Some(Route::Menu));
assert_eq!(route_for(false, false), None);
}

#[test]
fn a_slow_app_gets_the_dozen_tries_the_2x_needed() {
let mut attempt = Attempt::default();
Expand Down
2 changes: 2 additions & 0 deletions crates/cp-mac-sys/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ objc2.workspace = true
objc2-foundation.workspace = true
objc2-app-kit.workspace = true
objc2-core-graphics.workspace = true
objc2-core-foundation.workspace = true
objc2-application-services.workspace = true
objc2-vision.workspace = true
objc2-av-foundation.workspace = true

Expand Down
2 changes: 2 additions & 0 deletions crates/cp-mac-sys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ pub mod frontmost;
pub mod keyboard;
pub mod keystroke;
pub mod media;
pub mod menu;
pub mod ocr;
pub mod pasteboard;
pub mod paths;
pub mod permissions;
pub mod reading;
pub mod runloop;
107 changes: 107 additions & 0 deletions crates/cp-mac-sys/src/menu.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use objc2_application_services::{AXError, AXUIElement};
use objc2_core_foundation::{CFArray, CFBoolean, CFNumber, CFRetained, CFString, CFType};
use std::ptr::NonNull;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuFailure {
NoMenuBar,
NoPasteItem,
PasteDisabled,
PressRefused(i32),
}

pub fn press_paste(pid: i32) -> Result<(), MenuFailure> {
let app = unsafe { AXUIElement::new_application(pid) };
let bar = element(&app, "AXMenuBar").ok_or(MenuFailure::NoMenuBar)?;
let item = children(&bar)
.into_iter()
.flat_map(|menu| children(&menu))
.flat_map(|submenu| children(&submenu))
.find(|entry| is_paste_item(entry))
.ok_or(MenuFailure::NoPasteItem)?;
if !enabled(&item) {
return Err(MenuFailure::PasteDisabled);
}
let pressed = unsafe { item.perform_action(&CFString::from_str("AXPress")) };
if pressed == AXError::Success {
Ok(())
} else {
Err(MenuFailure::PressRefused(pressed.0))
}
}

pub fn is_paste_shortcut(command_char: &str, modifiers: u32) -> bool {
command_char.eq_ignore_ascii_case("v") && modifiers == 0
}

fn is_paste_item(entry: &AXUIElement) -> bool {
let Some(character) = attribute(entry, "AXMenuItemCmdChar") else {
return false;
};
let Some(character) = character.downcast_ref::<CFString>() else {
return false;
};
let modifiers = attribute(entry, "AXMenuItemCmdModifiers")
.and_then(|value| value.downcast_ref::<CFNumber>()?.as_i32())
.unwrap_or(0);
is_paste_shortcut(&character.to_string(), modifiers.unsigned_abs())
}

fn enabled(entry: &AXUIElement) -> bool {
attribute(entry, "AXEnabled")
.and_then(|value| Some(value.downcast_ref::<CFBoolean>()?.as_bool()))
.unwrap_or(false)
}

fn element(parent: &AXUIElement, name: &str) -> Option<CFRetained<AXUIElement>> {
attribute(parent, name)?.downcast::<AXUIElement>().ok()
}

fn children(parent: &AXUIElement) -> Vec<CFRetained<AXUIElement>> {
let Some(array) =
attribute(parent, "AXChildren").and_then(|value| value.downcast::<CFArray>().ok())
else {
return Vec::new();
};
let objects = unsafe { array.cast_unchecked::<CFType>() };
objects
.to_vec()
.into_iter()
.filter_map(|child| child.downcast::<AXUIElement>().ok())
.collect()
}

fn attribute(element: &AXUIElement, name: &str) -> Option<CFRetained<CFType>> {
let mut value: *const CFType = std::ptr::null();
let asked = unsafe {
element.copy_attribute_value(&CFString::from_str(name), NonNull::from(&mut value))
};
if asked != AXError::Success {
return None;
}
let raw = NonNull::new(value.cast_mut())?;
Some(unsafe { CFRetained::from_raw(raw) })
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn paste_is_command_v_and_nothing_else() {
assert!(is_paste_shortcut("v", 0));
assert!(is_paste_shortcut("V", 0));
assert!(
!is_paste_shortcut("v", 1),
"⇧⌘V es «pegar con el mismo estilo»"
);
assert!(!is_paste_shortcut("v", 2), "⌥⌘V es otra cosa");
assert!(!is_paste_shortcut("c", 0));
assert!(!is_paste_shortcut("", 0));
}

#[test]
fn a_process_that_does_not_exist_has_no_menu_bar() {
assert_eq!(press_paste(i32::MAX), Err(MenuFailure::NoMenuBar));
}
}
Loading
Loading