diff --git a/README.md b/README.md index 38d3076..9cfbbfb 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,9 @@ Rates are shared across queries and refreshed on the next currency query after t Search for **Conjure Emoji**, identified by its centered 🤔 icon, and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally. Favorites appear first, followed by recent selections when browsing All Emoji. -Use the arrow keys or Control-H/J/K/L to navigate left/down/up/right. Return or clicking an emoji copies it and dismisses Conjure, restoring focus to the previous app. Paste with Command-V; no Accessibility permission is needed. Escape or the Back chevron returns to the launcher and restores your search. +Use the arrow keys or Control-H/J/K/L to navigate left/down/up/right. Return, clicking an emoji, or **Copy and Paste** copies it, dismisses Conjure, and tries to paste into the app that was focused when the launcher opened. Automatic pasting requires **System Settings → Privacy & Security → Accessibility → Conjure**; macOS prompts if permission is missing. After granting it, select an emoji again. If permission is denied, the previous app is unavailable, or focus cannot be restored promptly, the emoji stays copied for manual Command-V. Escape or the Back chevron returns to the launcher and restores your search. -Open **Actions** with Command-K and choose **Favorite** or **Remove Favorite**. Favorites are saved locally and grouped ahead of other emoji, with a small extra spacing break when browsing without a search. There are no star badges. +Open **Actions** with Command-K for **Copy and Paste** (default), **Copy Emoji** (copy only, no Accessibility permission needed), **Favorite**, or **Remove Favorite**. Favorites are saved locally and grouped ahead of other emoji, with a small extra spacing break when browsing without a search. There are no star badges. The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application. diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 67f770d..8b5d414 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -10,7 +10,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text), ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text), ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text), - ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed) + ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed), + ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text) ] private var visibleEntries: [Int] = [] private var availableEntries = Array(0..<6) @@ -26,6 +27,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat private var transitionGeneration = 0 private var panel: ActionsPanelView { view as! ActionsPanelView } var open: (() -> Void)? + var copyEmoji: (() -> Void)? var toggleFavorite: (() -> Void)? var isFavorite = false var showInFinder: (() -> Void)? @@ -126,11 +128,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat func show(for application: Application, relativeTo anchor: NSView) { show(title: application.name, relativeTo: anchor, - available: application.command == nil ? Array(entries.indices) : [0, 1, 3]) + available: application.command == nil ? Array(0..<6) : [0, 1, 3]) } func showEmoji(named name: String, relativeTo anchor: NSView) { - show(title: name, relativeTo: anchor, available: [0, 1], emoji: true) + show(title: name, relativeTo: anchor, available: [0, 6, 1], emoji: true) } private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false) { @@ -140,8 +142,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat _ = view applicationLabel.stringValue = title availableEntries = available - entries[0].button.title = emoji ? "Copy Emoji" : "Open" - entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.doc" : "arrow.up.forward.app", accessibilityDescription: nil) + let openTitle = emoji ? "Copy and Paste" : "Open" + entries[0].button.title = openTitle + entries[0].button.setAccessibilityLabel(openTitle) + entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil) search.setAccessibilityLabel(emoji ? "Search emoji actions" : "Search application actions") let favorite = entries[1] let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" @@ -215,9 +219,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat func controlTextDidChange(_ notification: Notification) { let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) - visibleEntries = entries.indices.filter { - availableEntries.contains($0) - && (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil) + visibleEntries = availableEntries.filter { + query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil } selectedEntry = visibleEntries.first for index in entries.indices { @@ -278,6 +281,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat case 3: resetRanking?() case 4: quit?() case 5: uninstall?() + case 6: copyEmoji?() default: break } } diff --git a/Sources/Conjure/EmojiPickerController.swift b/Sources/Conjure/EmojiPickerController.swift index 7efe47d..e367721 100644 --- a/Sources/Conjure/EmojiPickerController.swift +++ b/Sources/Conjure/EmojiPickerController.swift @@ -7,7 +7,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, private let category = NSPopUpButton() private let status = NSTextField(labelWithString: "") private let empty = NSTextField(labelWithString: "No matching emoji") - private let copyButton = NSButton(title: "Copy Emoji", target: nil, action: nil) + private let copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil) private let actionsButton = NSButton(title: "Actions", target: nil, action: nil) private let actions = ApplicationActionsController() private var favoriteCount = 0 @@ -19,6 +19,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, var input: (() -> String)? var closed: (() -> Void)? var copied: (() -> Void)? + var pasteRequested: (() -> Void)? var focusSearch: (() -> Void)? init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) { @@ -59,7 +60,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, status.lineBreakMode = .byTruncatingTail status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) copyButton.target = self - copyButton.action = #selector(copySelection) + copyButton.action = #selector(copyAndPasteSelection) copyButton.keyEquivalent = "\r" copyButton.isBordered = false copyButton.font = .systemFont(ofSize: 12, weight: .medium) @@ -198,7 +199,8 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, guard results.indices.contains(selected) else { return } let emoji = results[selected] actions.isFavorite = (defaults.stringArray(forKey: "favoriteEmoji") ?? []).contains(emoji.value) - actions.open = { [weak self] in self?.copySelection() } + actions.open = { [weak self] in self?.copyAndPasteSelection() } + actions.copyEmoji = { [weak self] in self?.copySelection() } actions.toggleFavorite = { [weak self] in self?.toggleFavorite() } actions.showEmoji(named: emoji.name, relativeTo: actionsButton) copyButton.keyEquivalent = actions.isShown ? "" : "\r" @@ -220,7 +222,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1) case #selector(NSResponder.moveUp(_:)): moveVertically(-1) case #selector(NSResponder.moveDown(_:)): moveVertically(1) - case #selector(NSResponder.insertNewline(_:)): copySelection() + case #selector(NSResponder.insertNewline(_:)): copyAndPasteSelection() case #selector(NSResponder.cancelOperation(_:)): goBack() default: return false } @@ -238,7 +240,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, (collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true) } - @objc func copySelection() { + @objc func copySelection() { copySelection(pasting: false) } + @objc func copyAndPasteSelection() { copySelection(pasting: true) } + + private func copySelection(pasting: Bool) { guard results.indices.contains(selected) else { return } let value = results[selected].value pasteboard.clearContents() @@ -250,7 +255,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, recent.removeAll { $0 == value } defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji") closed?() - copied?() + if pasting { pasteRequested?() } else { copied?() } } func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 } @@ -273,7 +278,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, item.highlight(index == selected) item.clicked = { [weak self] in self?.selected = index - self?.copySelection() + self?.copyAndPasteSelection() } return item } diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index c461544..46554b7 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -1,6 +1,7 @@ import AppKit import ConjureKit import CoreText +import ApplicationServices @MainActor final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { @@ -760,6 +761,32 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } } + private func pasteEmoji() { + let target = previousApplication + let changeCount = NSPasteboard.general.changeCount + dismiss() + guard let target, !target.isTerminated, + target.processIdentifier != ProcessInfo.processInfo.processIdentifier, + AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) + else { return } + Task { [weak self] in + // Wait for focus restoration; never paste into a different app or a reopened launcher. + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(25)) + guard let self, self.window?.isVisible == false, !target.isTerminated, + NSPasteboard.general.changeCount == changeCount else { return } + guard NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier else { continue } + guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: true), + let up = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: false) else { return } + down.flags = .maskCommand + up.flags = .maskCommand + down.postToPid(target.processIdentifier) + up.postToPid(target.processIdentifier) + return + } + } + } + @objc private func goBackCommand() { translation?.goBack() emojiPicker?.goBack() @@ -790,6 +817,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe tool.input = { [weak self] in self?.searchField.stringValue ?? "" } tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) } tool.copied = { [weak self] in self?.dismiss() } + tool.pasteRequested = { [weak self] in self?.pasteEmoji() } controller = tool placeholder = "Search emoji by name or :shortcode:…" accessibilityLabel = "Search emoji" diff --git a/TODO.md b/TODO.md index 3a013b2..656df57 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,2 @@ -- [x] conjure should pop up quicker when launching than it currently does. -- [ ] conjure emoji selector should have an extra action (default) that both copies and tries to paste the selected emoji into the application that was focused when the conjure window was spawned. - [ ] add a notes app inspired by raycast notes - [ ] improve on the conjure translation command \ No newline at end of file diff --git a/Tests/ConjureKitTests/EmojiPickerTests.swift b/Tests/ConjureKitTests/EmojiPickerTests.swift index b63f683..ded2793 100644 --- a/Tests/ConjureKitTests/EmojiPickerTests.swift +++ b/Tests/ConjureKitTests/EmojiPickerTests.swift @@ -14,27 +14,36 @@ import Testing } var query = "face" var closed = false + var pasteRequests = 0 let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) picker.input = { query } picker.closed = { closed = true } + picker.pasteRequested = { + #expect(closed) + #expect(pasteboard.string(forType: .string) == EmojiCatalog.search(query)[1].value) + pasteRequests += 1 + } _ = picker.view let matches = EmojiCatalog.search(query) #expect(picker.handle(#selector(NSResponder.moveRight(_:)))) #expect(picker.handle(#selector(NSResponder.insertNewline(_:)))) #expect(pasteboard.string(forType: .string) == matches[1].value) #expect(closed) + #expect(pasteRequests == 1) #expect(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value]) closed = false query = "no-such-emoji" picker.inputChanged() - picker.copySelection() + picker.copyAndPasteSelection() #expect(!closed) #expect(pasteboard.string(forType: .string) == matches[1].value) + #expect(pasteRequests == 1) query = "👩‍💻" picker.inputChanged() picker.copySelection() #expect(pasteboard.string(forType: .string) == "👩‍💻") + #expect(pasteRequests == 1) let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) _ = reopened.view @@ -43,6 +52,39 @@ import Testing #expect(defaults.stringArray(forKey: "recentEmoji") == ["👩‍💻", matches[1].value]) } +@Test @MainActor func emojiActionsDefaultToPasteAndKeepCopyOnlyAvailable() throws { + _ = NSApplication.shared + let suite = "ConjureEmojiActions.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + let pasteboard = NSPasteboard.withUniqueName() + defer { defaults.removePersistentDomain(forName: suite); pasteboard.releaseGlobally() } + let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) + picker.input = { "👩‍💻" } + var pasteRequests = 0 + picker.pasteRequested = { pasteRequests += 1 } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 400), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = picker.view + picker.showActions() + #expect(picker.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(pasteboard.string(forType: .string) == "👩‍💻") + #expect(pasteRequests == 1) + + pasteboard.clearContents() + picker.showActions() + #expect(picker.handle(#selector(NSResponder.moveDown(_:)))) + #expect(picker.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(pasteboard.string(forType: .string) == "👩‍💻") + #expect(pasteRequests == 1) + + picker.showActions() + #expect(picker.handle(#selector(NSResponder.moveDown(_:)))) + #expect(picker.handle(#selector(NSResponder.moveDown(_:)))) + #expect(picker.handle(#selector(NSResponder.insertNewline(_:)))) + #expect(defaults.stringArray(forKey: "favoriteEmoji") == ["👩‍💻"]) + #expect(pasteRequests == 1) +} + @Test @MainActor func emojiCommandRestoresLauncherSearchOnEscape() throws { _ = NSApplication.shared let suite = "ConjureEmojiCommand.\(UUID().uuidString)"