feat: copy and paste selected emoji

This commit is contained in:
Valdemar Lorenzen
2026-10-16 15:11:07 +02:00
parent 9979639804
commit f8588adebe
6 changed files with 97 additions and 20 deletions
+2 -2
View File
@@ -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.
@@ -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
}
}
+12 -7
View File
@@ -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
}
@@ -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"
-2
View File
@@ -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
+43 -1
View File
@@ -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)"