feat: add searchable emoji picker

This commit is contained in:
Valdemar Lorenzen
2026-10-15 13:59:10 +02:00
parent 6574c97276
commit 1de7983345
12 changed files with 2322 additions and 43 deletions
+1
View File
@@ -8,6 +8,7 @@ build:
mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
cp Resources/Info.plist $(APP)/Contents/Info.plist cp Resources/Info.plist $(APP)/Contents/Info.plist
cp -R .build/release/Conjure_ConjureKit.bundle $(APP)/Contents/Resources/
swift Scripts/icon.swift .build/Conjure.iconset swift Scripts/icon.swift .build/Conjure.iconset
iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns
codesign --force --sign - $(APP) codesign --force --sign - $(APP)
+1 -1
View File
@@ -6,7 +6,7 @@ let package = Package(
platforms: [.macOS(.v14)], platforms: [.macOS(.v14)],
products: [.executable(name: "Conjure", targets: ["Conjure"])], products: [.executable(name: "Conjure", targets: ["Conjure"])],
targets: [ targets: [
.target(name: "ConjureKit"), .target(name: "ConjureKit", resources: [.process("Resources")]),
.executableTarget(name: "Conjure", dependencies: ["ConjureKit"]), .executableTarget(name: "Conjure", dependencies: ["ConjureKit"]),
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]), .testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
] ]
+9 -1
View File
@@ -1,6 +1,6 @@
# Conjure # Conjure
A native macOS launcher for applications, calculations, unit and currency conversions, and English → Japanese translation. A native macOS launcher for applications, calculations, unit and currency conversions, emoji, and English → Japanese translation.
## Build and install ## Build and install
@@ -75,6 +75,14 @@ Convert length, mass, temperature, volume, duration, speed, area, and data with
Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry. Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry.
## Emoji picker
Search for **Conjure Emoji** 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 and appear first when you reopen the picker.
Use the arrow keys to navigate; Control-J/Control-K move one emoji forward/back. 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.
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.
## Translation ## Translation
Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search. Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search.
+192
View File
@@ -0,0 +1,192 @@
import AppKit
import ConjureKit
@MainActor
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource {
private let collection = NSCollectionView()
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 defaults: UserDefaults
private let pasteboard: NSPasteboard
private var results: [Emoji] = []
private var selected = 0
var input: (() -> String)?
var closed: (() -> Void)?
var copied: (() -> Void)?
init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
self.defaults = defaults
self.pasteboard = pasteboard
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("Conjure creates its emoji picker programmatically") }
override func loadView() {
view = NSView()
category.addItems(withTitles: ["All Emoji", "Recently Used"] + EmojiCatalog.categories)
category.setAccessibilityLabel("Emoji category")
category.target = self
category.action = #selector(categoryChanged)
let layout = NSCollectionViewFlowLayout()
layout.itemSize = NSSize(width: 64, height: 56)
layout.minimumInteritemSpacing = 6
layout.minimumLineSpacing = 6
layout.sectionInset = NSEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
collection.collectionViewLayout = layout
collection.backgroundColors = [.clear]
collection.dataSource = self
collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji"))
collection.setAccessibilityLabel("Emoji grid")
let scroll = LauncherScrollView()
scroll.documentView = collection
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.scrollerStyle = .overlay
scroll.drawsBackground = false
scroll.contentView.drawsBackground = false
empty.textColor = SunsetPalette.secondaryText
status.font = .systemFont(ofSize: 12)
status.textColor = SunsetPalette.secondaryText
status.lineBreakMode = .byTruncatingTail
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
copyButton.target = self
copyButton.action = #selector(copySelection)
copyButton.keyEquivalent = "\r"
copyButton.isBordered = false
copyButton.font = .systemFont(ofSize: 12, weight: .medium)
copyButton.contentTintColor = SunsetPalette.text
let footer = NSStackView(views: [status, NSView(), copyButton, ShortcutIndicator("↵")])
footer.spacing = 8
for child in [category, scroll, empty, footer] {
child.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child)
}
NSLayoutConstraint.activate([
category.topAnchor.constraint(equalTo: view.topAnchor),
category.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
scroll.topAnchor.constraint(equalTo: category.bottomAnchor, constant: 4),
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -4),
empty.centerXAnchor.constraint(equalTo: scroll.centerXAnchor),
empty.centerYAnchor.constraint(equalTo: scroll.centerYAnchor),
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
footer.heightAnchor.constraint(equalToConstant: 36),
])
inputChanged()
}
@objc private func categoryChanged() { inputChanged() }
@objc func goBack() { closed?() }
override func cancelOperation(_ sender: Any?) { goBack() }
func inputChanged() {
let recent = defaults.stringArray(forKey: "recentEmoji") ?? []
let matches = EmojiCatalog.search(input?() ?? "", category: category.indexOfSelectedItem > 1 ? category.titleOfSelectedItem : nil)
if category.indexOfSelectedItem == 1 {
results = recent.compactMap { value in matches.first { $0.value == value } }
} else if category.indexOfSelectedItem == 0, (input?() ?? "").isEmpty {
let recentSet = Set(recent)
results = recent.compactMap { value in matches.first { $0.value == value } }
+ matches.filter { !recentSet.contains($0.value) }
} else {
results = matches
}
selected = 0
collection.reloadData()
collection.scroll(NSPoint.zero)
updateSelection()
}
func moveSelection(by offset: Int) {
guard !results.isEmpty else { return }
let old = selected
selected = min(max(selected + offset, 0), results.count - 1)
(collection.item(at: IndexPath(item: old, section: 0)) as? EmojiItem)?.highlight(false)
updateSelection()
collection.scrollToItems(at: [IndexPath(item: selected, section: 0)], scrollPosition: .nearestVerticalEdge)
}
func handle(_ selector: Selector) -> Bool {
let columns = max(1, Int((collection.bounds.width - 32 + 6) / 70))
switch selector {
case #selector(NSResponder.moveLeft(_:)): moveSelection(by: -1)
case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1)
case #selector(NSResponder.moveUp(_:)): moveSelection(by: -columns)
case #selector(NSResponder.moveDown(_:)): moveSelection(by: columns)
case #selector(NSResponder.insertNewline(_:)): copySelection()
case #selector(NSResponder.cancelOperation(_:)): goBack()
default: return false
}
return true
}
private func updateSelection() {
empty.isHidden = !results.isEmpty
if results.isEmpty {
empty.stringValue = category.indexOfSelectedItem == 1 ? "No recently used emoji match" : "No matching emoji"
}
copyButton.isEnabled = !results.isEmpty
status.stringValue = results.isEmpty ? "0 emoji" : "\(results[selected].name) · \(results.count) emoji"
(collection.item(at: IndexPath(item: selected, section: 0)) as? EmojiItem)?.highlight(true)
}
@objc func copySelection() {
guard results.indices.contains(selected) else { return }
let value = results[selected].value
pasteboard.clearContents()
guard pasteboard.setString(value, forType: .string) else {
status.stringValue = "Couldn’t copy emoji. Try again."
return
}
var recent = defaults.stringArray(forKey: "recentEmoji") ?? []
recent.removeAll { $0 == value }
defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji")
closed?()
copied?()
}
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { results.count }
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("emoji"), for: indexPath) as! EmojiItem
let emoji = results[indexPath.item]
item.button.title = emoji.value
item.button.toolTip = emoji.name
item.button.setAccessibilityLabel(emoji.name)
item.highlight(indexPath.item == selected)
item.clicked = { [weak self] in
self?.selected = indexPath.item
self?.copySelection()
}
return item
}
}
@MainActor
private final class EmojiItem: NSCollectionViewItem {
let button = NSButton()
var clicked: (() -> Void)?
override func loadView() {
button.font = .systemFont(ofSize: 32)
button.isBordered = false
button.target = self
button.action = #selector(copyEmoji)
button.wantsLayer = true
button.layer?.cornerRadius = 10
view = button
}
func highlight(_ selected: Bool) {
button.layer?.backgroundColor = selected ? SunsetPalette.accent.withAlphaComponent(0.22).cgColor : nil
button.setAccessibilitySelected(selected)
}
@objc private func copyEmoji() { clicked?() }
}
+68 -40
View File
@@ -5,7 +5,7 @@ import ConjureKit
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField() private let searchField = NSSearchField()
private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
private let translationBack = NSButton(title: "", target: nil, action: nil) private let commandBack = NSButton(title: "", target: nil, action: nil)
private let tableView = ApplicationTableView() private let tableView = ApplicationTableView()
private let statusLabel = NSTextField(labelWithString: "Finding applications…") private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…") private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
@@ -34,6 +34,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var launcherViews: [NSView] = [] private var launcherViews: [NSView] = []
private var launcherFrameBeforeReview: NSRect? private var launcherFrameBeforeReview: NSRect?
private var translation: TranslationController? private var translation: TranslationController?
private var emojiPicker: EmojiPickerController?
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) { init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history self.history = history
@@ -61,13 +63,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard let self else { return } guard let self else { return }
if let review = self.uninstallReview { review.moveSelection(offset) } if let review = self.uninstallReview { review.moveSelection(offset) }
else if self.actions.isShown { self.actions.moveSelection(by: offset) } else if self.actions.isShown { self.actions.moveSelection(by: offset) }
else if let picker = self.emojiPicker { picker.moveSelection(by: offset) }
else { self.moveSelection(by: offset) } else { self.moveSelection(by: offset) }
} }
window.dismiss = { [weak self] in self?.dismiss() } window.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false } guard let self else { return false }
return !self.isLaunching && self.calculation == nil && self.resultCount > 0 return !self.isLaunching && self.calculation == nil && self.resultCount > 0
&& self.launcherFrameBeforeReview == nil && !self.actions.isShown && self.translation == nil && self.launcherFrameBeforeReview == nil && !self.actions.isShown && !self.isCommandOpen
} }
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() } window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
window.launchNumber = { [weak self] index in window.launchNumber = { [weak self] index in
@@ -98,7 +101,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func present() { func present() {
(window as? LauncherPanel)?.beginNumberHintSession() (window as? LauncherPanel)?.beginNumberHintSession()
if launcherFrameBeforeReview != nil || translation != nil { if launcherFrameBeforeReview != nil || isCommandOpen {
showWindow(nil) showWindow(nil)
NSApp.activate() NSApp.activate()
window?.makeKeyAndOrderFront(nil) window?.makeKeyAndOrderFront(nil)
@@ -134,8 +137,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) { func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return } guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); return } if let review = uninstallReview { review.goBack(); return }
if let translation { if isCommandOpen {
translation.goBack() goBackCommand()
if restoringFocus { return } if restoringFocus { return }
} }
guard launcherFrameBeforeReview == nil else { return } guard launcherFrameBeforeReview == nil else { return }
@@ -230,18 +233,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
searchSymbol.contentTintColor = SunsetPalette.accent searchSymbol.contentTintColor = SunsetPalette.accent
translationBack.image = IconStyle.symbol("chevron.left", for: searchFont) commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
translationBack.imageScaling = .scaleProportionallyDown commandBack.imageScaling = .scaleProportionallyDown
translationBack.isBordered = false commandBack.isBordered = false
translationBack.contentTintColor = SunsetPalette.secondaryText commandBack.contentTintColor = SunsetPalette.secondaryText
translationBack.setAccessibilityLabel("Back to applications") commandBack.setAccessibilityLabel("Back to applications")
translationBack.toolTip = "Back to applications (Escape)" commandBack.toolTip = "Back to applications (Escape)"
translationBack.isHidden = true commandBack.isHidden = true
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
translationBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
translationBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
]) ])
let header = NSStackView(views: [searchSymbol, translationBack, searchField]) let header = NSStackView(views: [searchSymbol, commandBack, searchField])
header.orientation = .horizontal header.orientation = .horizontal
header.alignment = .centerY header.alignment = .centerY
header.spacing = 12 header.spacing = 12
@@ -431,7 +434,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) { private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
guard translation == nil else { return } guard !isCommandOpen else { return }
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow) let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil ? results[tableView.selectedRow].id : nil
let query = searchField.stringValue let query = searchField.stringValue
@@ -497,10 +500,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
if let translation { translation.inputChanged(); return } if let translation { translation.inputChanged(); return }
if let emojiPicker { emojiPicker.inputChanged(); return }
updateResults() updateResults()
} }
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if let emojiPicker { return emojiPicker.handle(commandSelector) }
if let translation { if let translation {
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true } if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true }
if commandSelector == #selector(NSResponder.cancelOperation(_:)) { translation.goBack(); return true } if commandSelector == #selector(NSResponder.cancelOperation(_:)) { translation.goBack(); return true }
@@ -524,7 +529,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var resultCount: Int { calculation == nil ? results.count : 1 } private var resultCount: Int { calculation == nil ? results.count : 1 }
private func moveSelection(by offset: Int) { private func moveSelection(by offset: Int) {
guard translation == nil else { return } guard !isCommandOpen else { return }
guard resultCount > 0 else { return } guard resultCount > 0 else { return }
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1) let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
@@ -532,7 +537,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
private func updateOpenButton() { private func updateOpenButton() {
let canAct = !isLaunching && launcherFrameBeforeReview == nil && translation == nil && tableView.selectedRow >= 0 let canAct = !isLaunching && launcherFrameBeforeReview == nil && !isCommandOpen && tableView.selectedRow >= 0
openButton.title = calculation == nil openButton.title = calculation == nil
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil ? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
? "Open Command" : "Open Application") : "Copy Answer" ? "Open Command" : "Open Application") : "Copy Answer"
@@ -584,9 +589,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.locationLabel.isHidden = true cell.locationLabel.isHidden = true
cell.kindLabel.attributedStringValue = history.isFavorite(entry) cell.kindLabel.attributedStringValue = history.isFavorite(entry)
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind ? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil) cell.imageView?.image = NSImage(systemSymbolName: entry.command == .emoji ? "face.smiling" : "character.bubble", accessibilityDescription: nil)
cell.imageView?.contentTintColor = SunsetPalette.accent cell.imageView?.contentTintColor = SunsetPalette.accent
cell.toolTip = "Translate English to Japanese" cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
} else { } else {
let application = results[row] let application = results[row]
cell.textField?.stringValue = application.name cell.textField?.stringValue = application.name
@@ -616,7 +621,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) { private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
let visible = (window as? LauncherPanel)?.numberHintsVisible == true let visible = (window as? LauncherPanel)?.numberHintsVisible == true
&& calculation == nil && row < 9 && row < resultCount && translation == nil && calculation == nil && row < 9 && row < resultCount && !isCommandOpen
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated) cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil) cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
} }
@@ -639,7 +644,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func showActions() { @objc private func showActions() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return } guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
guard translation == nil else { return } guard !isCommandOpen else { return }
(window as? LauncherPanel)?.resetNumberHints() (window as? LauncherPanel)?.resetNumberHints()
let application = results[tableView.selectedRow] let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) } actions.open = { [weak self] in self?.openApplication(application) }
@@ -727,7 +732,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
@objc private func openSelection() { @objc private func openSelection() {
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let calculation { if let calculation {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return } guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -753,11 +758,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
private func openApplication(_ application: Application) { private func openApplication(_ application: Application) {
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
if let command = application.command { if let command = application.command {
switch command { showCommand(command)
case .translate: showTranslation()
}
history.recordLaunch(of: application) history.recordLaunch(of: application)
return return
} }
@@ -781,19 +784,42 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} }
} }
private func showTranslation() { @objc private func goBackCommand() {
guard translation == nil, let content = window?.contentView else { return } translation?.goBack()
let controller = TranslationController() emojiPicker?.goBack()
translation = controller }
controller.input = { [weak self] in self?.searchField.stringValue ?? "" }
private func showCommand(_ command: Application.Command) {
guard !isCommandOpen, let content = window?.contentView else { return }
let controller: NSViewController
let placeholder: String
let accessibilityLabel: String
switch command {
case .translate:
let tool = TranslationController()
translation = tool
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
controller = tool
placeholder = "Enter English text to translate…"
accessibilityLabel = "English text to translate into Japanese"
case .emoji:
let tool = EmojiPickerController()
emojiPicker = tool
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
tool.copied = { [weak self] in self?.dismiss() }
controller = tool
placeholder = "Search emoji by name or :shortcode:…"
accessibilityLabel = "Search emoji"
}
let previousQuery = searchField.stringValue let previousQuery = searchField.stringValue
let previousPlaceholder = searchField.placeholderAttributedString let previousPlaceholder = searchField.placeholderAttributedString
controller.closed = { [weak self, weak controller] in let close: () -> Void = { [weak self, weak controller] in
guard let self, let controller else { return } guard let self, let controller else { return }
controller.view.removeFromSuperview() controller.view.removeFromSuperview()
self.translation = nil self.translation = nil
self.translationBack.isHidden = true self.emojiPicker = nil
self.translationBack.target = nil self.commandBack.isHidden = true
self.commandBack.target = nil
self.searchSymbol.isHidden = false self.searchSymbol.isHidden = false
for view in self.launcherViews.dropFirst() { for view in self.launcherViews.dropFirst() {
view.isHidden = false view.isHidden = false
@@ -807,19 +833,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.updateResults() self.updateResults()
self.window?.makeFirstResponder(self.searchField) self.window?.makeFirstResponder(self.searchField)
} }
translation?.closed = close
emojiPicker?.closed = close
actions.close() actions.close()
(window as? LauncherPanel)?.resetNumberHints() (window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = "" openButton.keyEquivalent = ""
searchSymbol.isHidden = true searchSymbol.isHidden = true
translationBack.target = controller commandBack.target = self
translationBack.action = #selector(TranslationController.goBack) commandBack.action = #selector(goBackCommand)
translationBack.isHidden = false commandBack.isHidden = false
searchField.stringValue = "" searchField.stringValue = ""
searchField.placeholderAttributedString = NSAttributedString( searchField.placeholderAttributedString = NSAttributedString(
string: "Enter English text to translate…", string: placeholder,
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:] attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
) )
searchField.setAccessibilityLabel("English text to translate into Japanese") searchField.setAccessibilityLabel(accessibilityLabel)
for view in launcherViews.dropFirst() { for view in launcherViews.dropFirst() {
view.isHidden = true view.isHidden = true
view.setAccessibilityHidden(true) view.setAccessibilityHidden(true)
+2
View File
@@ -3,6 +3,7 @@ import Foundation
public struct Application: Identifiable, Sendable { public struct Application: Identifiable, Sendable {
public enum Command: String, CaseIterable, Sendable { public enum Command: String, CaseIterable, Sendable {
case translate case translate
case emoji
} }
public static let commands = Command.allCases.map { Application(command: $0) } public static let commands = Command.allCases.map { Application(command: $0) }
public let command: Command? public let command: Command?
@@ -20,6 +21,7 @@ public struct Application: Identifiable, Sendable {
public init(command: Command) { public init(command: Command) {
let name = switch command { let name = switch command {
case .translate: "Conjure Translate" case .translate: "Conjure Translate"
case .emoji: "Conjure Emoji"
} }
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command) self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
} }
+61
View File
@@ -0,0 +1,61 @@
import Foundation
public struct Emoji: Sendable {
public let value: String
public let name: String
public let category: String
}
public enum EmojiCatalog {
private struct Entry: Sendable {
let emoji: Emoji
let searchText: String
}
private static let entries: [Entry] = {
// SwiftPM's generated accessor does not search Contents/Resources in a packaged app.
let packagedBundle = Bundle.main.resourceURL.flatMap {
Bundle(url: $0.appendingPathComponent("Conjure_ConjureKit.bundle", isDirectory: true))
}
let bundle = packagedBundle ?? Bundle.module
guard let url = bundle.url(forResource: "emoji", withExtension: "tsv"),
let contents = try? String(contentsOf: url, encoding: .utf8) else {
preconditionFailure("The bundled emoji catalog is missing or unreadable")
}
// Pinned github/gemoji v4.1.0; attribution and license accompany the TSV.
return contents.split(whereSeparator: \.isNewline).map { line in
let fields = line.split(separator: "\t", omittingEmptySubsequences: false)
precondition(fields.count == 5, "Invalid bundled emoji catalog row")
return Entry(
emoji: Emoji(value: String(fields[0]), name: String(fields[1]), category: String(fields[2])),
searchText: normalized([fields[0], fields[1], fields[3], fields[4]].joined(separator: " "))
)
}
}()
public static let all: [Emoji] = entries.map(\.emoji)
public static let categories: [String] = {
var seen = Set<String>()
return all.compactMap { seen.insert($0.category).inserted ? $0.category : nil }
}()
/// Matches every query word against names, GitHub aliases, tags, or literal emoji.
/// Category names use the exact values supplied by `categories`; results retain catalog order.
public static func search(_ query: String, category: String? = nil) -> [Emoji] {
let words = normalized(query).split(whereSeparator: \.isWhitespace)
if words.isEmpty && category == nil { return all }
return entries.compactMap { entry in
guard category == nil || entry.emoji.category == category,
words.allSatisfy({ entry.searchText.contains($0) }) else { return nil }
return entry.emoji
}
}
private static func normalized(_ text: String) -> String {
text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX"))
.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: ":", with: " ")
}
}
@@ -0,0 +1,28 @@
Emoji catalog derived from github/gemoji v4.1.0.
Source: https://raw.githubusercontent.com/github/gemoji/v4.1.0/db/emoji.json
License: https://raw.githubusercontent.com/github/gemoji/v4.1.0/LICENSE
TSV columns: emoji, description, category, space-separated aliases, space-separated tags.
Copyright (c) 2019 GitHub, Inc.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,3 +1,3 @@
- [ ] add an emoji picker inspired raycast's emoji picker - [x] add an emoji picker inspired raycast's emoji picker
- [ ] 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
@@ -0,0 +1,20 @@
import ConjureKit
import Testing
@Test func emojiCatalogSupportsOfflineSearchAndOrderedCategories() {
#expect(EmojiCatalog.search(" \n ").map(\.value) == EmojiCatalog.all.map(\.value))
#expect(EmojiCatalog.search("", category: "Flags").map(\.value)
== EmojiCatalog.all.filter { $0.category == "Flags" }.map(\.value))
// Names, shortcodes, aliases, tags, and literals share the same search path.
#expect(EmojiCatalog.search("woman tech").map(\.value) == ["👩‍💻"])
#expect(EmojiCatalog.search(":WOMAN_TECHNOLOGIST:").map(\.value) == ["👩‍💻"])
#expect(EmojiCatalog.search("coder woman", category: "People & Body").map(\.value) == ["👩‍💻"])
#expect(EmojiCatalog.search("coder woman", category: "Objects").isEmpty)
#expect(EmojiCatalog.search(":+1:").map(\.value) == ["👍"])
#expect(EmojiCatalog.search("CÁFÉ", category: "Food & Drink").contains { $0.value == "☕" })
#expect(EmojiCatalog.search("👩‍💻").map(\.value) == ["👩‍💻"])
#expect(EmojiCatalog.search("🇩🇰", category: "Flags").map(\.value) == ["🇩🇰"])
#expect(EmojiCatalog.search("no-such-emoji").isEmpty)
#expect(EmojiCatalog.search("", category: "no-such-category").isEmpty)
}
@@ -0,0 +1,69 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor func emojiPickerNavigatesCopiesAndRemembersRecentSelections() throws {
_ = NSApplication.shared
let suite = "ConjureEmoji.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let pasteboard = NSPasteboard.withUniqueName()
defer {
defaults.removePersistentDomain(forName: suite)
pasteboard.releaseGlobally()
}
var query = "face"
var closed = false
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
picker.input = { query }
picker.closed = { closed = true }
_ = 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(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value])
closed = false
query = "no-such-emoji"
picker.inputChanged()
picker.copySelection()
#expect(!closed)
#expect(pasteboard.string(forType: .string) == matches[1].value)
query = "👩‍💻"
picker.inputChanged()
picker.copySelection()
#expect(pasteboard.string(forType: .string) == "👩‍💻")
let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
_ = reopened.view
reopened.copySelection()
#expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(defaults.stringArray(forKey: "recentEmoji") == ["👩‍💻", matches[1].value])
}
@Test @MainActor func emojiCommandRestoresLauncherSearchOnEscape() throws {
_ = NSApplication.shared
let suite = "ConjureEmojiCommand.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })
let launcher = LauncherWindowController(history: history)
let content = try #require(launcher.window?.contentView)
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "emoji"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
let grid = try #require(descendants(content).compactMap { $0 as? NSCollectionView }.first)
search.stringValue = ":woman_technologist:"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
#expect(grid.numberOfItems(inSection: 0) == 1)
#expect((grid.dataSource?.collectionView(grid, itemForRepresentedObjectAt: IndexPath(item: 0, section: 0)).view as? NSButton)?.title == "👩‍💻")
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
#expect(!descendants(content).contains { $0 === grid })
#expect(search.stringValue == "emoji")
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first)
#expect(table.numberOfRows == 1)
}