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. 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. 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: "Show in Finder", symbol: "finder", color: SunsetPalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", 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: "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 visibleEntries: [Int] = []
private var availableEntries = Array(0..<6) private var availableEntries = Array(0..<6)
@@ -26,6 +27,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
private var transitionGeneration = 0 private var transitionGeneration = 0
private var panel: ActionsPanelView { view as! ActionsPanelView } private var panel: ActionsPanelView { view as! ActionsPanelView }
var open: (() -> Void)? var open: (() -> Void)?
var copyEmoji: (() -> Void)?
var toggleFavorite: (() -> Void)? var toggleFavorite: (() -> Void)?
var isFavorite = false var isFavorite = false
var showInFinder: (() -> Void)? var showInFinder: (() -> Void)?
@@ -126,11 +128,11 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func show(for application: Application, relativeTo anchor: NSView) { func show(for application: Application, relativeTo anchor: NSView) {
show(title: application.name, relativeTo: anchor, 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) { 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) { private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false) {
@@ -140,8 +142,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
_ = view _ = view
applicationLabel.stringValue = title applicationLabel.stringValue = title
availableEntries = available availableEntries = available
entries[0].button.title = emoji ? "Copy Emoji" : "Open" let openTitle = emoji ? "Copy and Paste" : "Open"
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.doc" : "arrow.up.forward.app", accessibilityDescription: nil) 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") search.setAccessibilityLabel(emoji ? "Search emoji actions" : "Search application actions")
let favorite = entries[1] let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
@@ -215,9 +219,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = entries.indices.filter { visibleEntries = availableEntries.filter {
availableEntries.contains($0) query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
&& (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil)
} }
selectedEntry = visibleEntries.first selectedEntry = visibleEntries.first
for index in entries.indices { for index in entries.indices {
@@ -278,6 +281,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 3: resetRanking?() case 3: resetRanking?()
case 4: quit?() case 4: quit?()
case 5: uninstall?() case 5: uninstall?()
case 6: copyEmoji?()
default: break default: break
} }
} }
+12 -7
View File
@@ -7,7 +7,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
private let category = NSPopUpButton() private let category = NSPopUpButton()
private let status = NSTextField(labelWithString: "") private let status = NSTextField(labelWithString: "")
private let empty = NSTextField(labelWithString: "No matching emoji") 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 actionsButton = NSButton(title: "Actions", target: nil, action: nil)
private let actions = ApplicationActionsController() private let actions = ApplicationActionsController()
private var favoriteCount = 0 private var favoriteCount = 0
@@ -19,6 +19,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
var input: (() -> String)? var input: (() -> String)?
var closed: (() -> Void)? var closed: (() -> Void)?
var copied: (() -> Void)? var copied: (() -> Void)?
var pasteRequested: (() -> Void)?
var focusSearch: (() -> Void)? var focusSearch: (() -> Void)?
init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) { init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
@@ -59,7 +60,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
status.lineBreakMode = .byTruncatingTail status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
copyButton.target = self copyButton.target = self
copyButton.action = #selector(copySelection) copyButton.action = #selector(copyAndPasteSelection)
copyButton.keyEquivalent = "\r" copyButton.keyEquivalent = "\r"
copyButton.isBordered = false copyButton.isBordered = false
copyButton.font = .systemFont(ofSize: 12, weight: .medium) copyButton.font = .systemFont(ofSize: 12, weight: .medium)
@@ -198,7 +199,8 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
guard results.indices.contains(selected) else { return } guard results.indices.contains(selected) else { return }
let emoji = results[selected] let emoji = results[selected]
actions.isFavorite = (defaults.stringArray(forKey: "favoriteEmoji") ?? []).contains(emoji.value) 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.toggleFavorite = { [weak self] in self?.toggleFavorite() }
actions.showEmoji(named: emoji.name, relativeTo: actionsButton) actions.showEmoji(named: emoji.name, relativeTo: actionsButton)
copyButton.keyEquivalent = actions.isShown ? "" : "\r" copyButton.keyEquivalent = actions.isShown ? "" : "\r"
@@ -220,7 +222,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1) case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1)
case #selector(NSResponder.moveUp(_:)): moveVertically(-1) case #selector(NSResponder.moveUp(_:)): moveVertically(-1)
case #selector(NSResponder.moveDown(_:)): moveVertically(1) case #selector(NSResponder.moveDown(_:)): moveVertically(1)
case #selector(NSResponder.insertNewline(_:)): copySelection() case #selector(NSResponder.insertNewline(_:)): copyAndPasteSelection()
case #selector(NSResponder.cancelOperation(_:)): goBack() case #selector(NSResponder.cancelOperation(_:)): goBack()
default: return false default: return false
} }
@@ -238,7 +240,10 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
(collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true) (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 } guard results.indices.contains(selected) else { return }
let value = results[selected].value let value = results[selected].value
pasteboard.clearContents() pasteboard.clearContents()
@@ -250,7 +255,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
recent.removeAll { $0 == value } recent.removeAll { $0 == value }
defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji") defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji")
closed?() closed?()
copied?() if pasting { pasteRequested?() } else { copied?() }
} }
func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 } func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 }
@@ -273,7 +278,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
item.highlight(index == selected) item.highlight(index == selected)
item.clicked = { [weak self] in item.clicked = { [weak self] in
self?.selected = index self?.selected = index
self?.copySelection() self?.copyAndPasteSelection()
} }
return item return item
} }
@@ -1,6 +1,7 @@
import AppKit import AppKit
import ConjureKit import ConjureKit
import CoreText import CoreText
import ApplicationServices
@MainActor @MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { 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() { @objc private func goBackCommand() {
translation?.goBack() translation?.goBack()
emojiPicker?.goBack() emojiPicker?.goBack()
@@ -790,6 +817,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
tool.input = { [weak self] in self?.searchField.stringValue ?? "" } tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) } tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
tool.copied = { [weak self] in self?.dismiss() } tool.copied = { [weak self] in self?.dismiss() }
tool.pasteRequested = { [weak self] in self?.pasteEmoji() }
controller = tool controller = tool
placeholder = "Search emoji by name or :shortcode:…" placeholder = "Search emoji by name or :shortcode:…"
accessibilityLabel = "Search emoji" 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 - [ ] add a notes app inspired by raycast notes
- [ ] improve on the conjure translation command - [ ] improve on the conjure translation command
+43 -1
View File
@@ -14,27 +14,36 @@ import Testing
} }
var query = "face" var query = "face"
var closed = false var closed = false
var pasteRequests = 0
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
picker.input = { query } picker.input = { query }
picker.closed = { closed = true } picker.closed = { closed = true }
picker.pasteRequested = {
#expect(closed)
#expect(pasteboard.string(forType: .string) == EmojiCatalog.search(query)[1].value)
pasteRequests += 1
}
_ = picker.view _ = picker.view
let matches = EmojiCatalog.search(query) let matches = EmojiCatalog.search(query)
#expect(picker.handle(#selector(NSResponder.moveRight(_:)))) #expect(picker.handle(#selector(NSResponder.moveRight(_:))))
#expect(picker.handle(#selector(NSResponder.insertNewline(_:)))) #expect(picker.handle(#selector(NSResponder.insertNewline(_:))))
#expect(pasteboard.string(forType: .string) == matches[1].value) #expect(pasteboard.string(forType: .string) == matches[1].value)
#expect(closed) #expect(closed)
#expect(pasteRequests == 1)
#expect(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value]) #expect(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value])
closed = false closed = false
query = "no-such-emoji" query = "no-such-emoji"
picker.inputChanged() picker.inputChanged()
picker.copySelection() picker.copyAndPasteSelection()
#expect(!closed) #expect(!closed)
#expect(pasteboard.string(forType: .string) == matches[1].value) #expect(pasteboard.string(forType: .string) == matches[1].value)
#expect(pasteRequests == 1)
query = "👩‍💻" query = "👩‍💻"
picker.inputChanged() picker.inputChanged()
picker.copySelection() picker.copySelection()
#expect(pasteboard.string(forType: .string) == "👩‍💻") #expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(pasteRequests == 1)
let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
_ = reopened.view _ = reopened.view
@@ -43,6 +52,39 @@ import Testing
#expect(defaults.stringArray(forKey: "recentEmoji") == ["👩‍💻", matches[1].value]) #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 { @Test @MainActor func emojiCommandRestoresLauncherSearchOnEscape() throws {
_ = NSApplication.shared _ = NSApplication.shared
let suite = "ConjureEmojiCommand.\(UUID().uuidString)" let suite = "ConjureEmojiCommand.\(UUID().uuidString)"