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
+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 {
private let searchField = NSSearchField()
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 statusLabel = 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 launcherFrameBeforeReview: NSRect?
private var translation: TranslationController?
private var emojiPicker: EmojiPickerController?
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history
@@ -61,13 +63,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard let self else { return }
if let review = self.uninstallReview { review.moveSelection(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) }
}
window.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false }
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.launchNumber = { [weak self] index in
@@ -98,7 +101,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func present() {
(window as? LauncherPanel)?.beginNumberHintSession()
if launcherFrameBeforeReview != nil || translation != nil {
if launcherFrameBeforeReview != nil || isCommandOpen {
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
@@ -134,8 +137,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); return }
if let translation {
translation.goBack()
if isCommandOpen {
goBackCommand()
if restoringFocus { return }
}
guard launcherFrameBeforeReview == nil else { return }
@@ -230,18 +233,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
searchSymbol.contentTintColor = SunsetPalette.accent
translationBack.image = IconStyle.symbol("chevron.left", for: searchFont)
translationBack.imageScaling = .scaleProportionallyDown
translationBack.isBordered = false
translationBack.contentTintColor = SunsetPalette.secondaryText
translationBack.setAccessibilityLabel("Back to applications")
translationBack.toolTip = "Back to applications (Escape)"
translationBack.isHidden = true
commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
commandBack.imageScaling = .scaleProportionallyDown
commandBack.isBordered = false
commandBack.contentTintColor = SunsetPalette.secondaryText
commandBack.setAccessibilityLabel("Back to applications")
commandBack.toolTip = "Back to applications (Escape)"
commandBack.isHidden = true
NSLayoutConstraint.activate([
translationBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
translationBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
commandBack.widthAnchor.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.alignment = .centerY
header.spacing = 12
@@ -431,7 +434,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
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)
? results[tableView.selectedRow].id : nil
let query = searchField.stringValue
@@ -497,10 +500,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func controlTextDidChange(_ notification: Notification) {
if let translation { translation.inputChanged(); return }
if let emojiPicker { emojiPicker.inputChanged(); return }
updateResults()
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if let emojiPicker { return emojiPicker.handle(commandSelector) }
if let translation {
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); 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 func moveSelection(by offset: Int) {
guard translation == nil else { return }
guard !isCommandOpen else { return }
guard resultCount > 0 else { return }
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
@@ -532,7 +537,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
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
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
? "Open Command" : "Open Application") : "Copy Answer"
@@ -584,9 +589,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.locationLabel.isHidden = true
cell.kindLabel.attributedStringValue = history.isFavorite(entry)
? 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.toolTip = "Translate English to Japanese"
cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
} else {
let application = results[row]
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) {
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.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
}
@@ -639,7 +644,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func showActions() {
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()
let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) }
@@ -727,7 +732,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
@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 {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
@@ -753,11 +758,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
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 {
switch command {
case .translate: showTranslation()
}
showCommand(command)
history.recordLaunch(of: application)
return
}
@@ -781,19 +784,42 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func showTranslation() {
guard translation == nil, let content = window?.contentView else { return }
let controller = TranslationController()
translation = controller
controller.input = { [weak self] in self?.searchField.stringValue ?? "" }
@objc private func goBackCommand() {
translation?.goBack()
emojiPicker?.goBack()
}
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 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 }
controller.view.removeFromSuperview()
self.translation = nil
self.translationBack.isHidden = true
self.translationBack.target = nil
self.emojiPicker = nil
self.commandBack.isHidden = true
self.commandBack.target = nil
self.searchSymbol.isHidden = false
for view in self.launcherViews.dropFirst() {
view.isHidden = false
@@ -807,19 +833,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.updateResults()
self.window?.makeFirstResponder(self.searchField)
}
translation?.closed = close
emojiPicker?.closed = close
actions.close()
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
searchSymbol.isHidden = true
translationBack.target = controller
translationBack.action = #selector(TranslationController.goBack)
translationBack.isHidden = false
commandBack.target = self
commandBack.action = #selector(goBackCommand)
commandBack.isHidden = false
searchField.stringValue = ""
searchField.placeholderAttributedString = NSAttributedString(
string: "Enter English text to translate…",
string: placeholder,
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
)
searchField.setAccessibilityLabel("English text to translate into Japanese")
searchField.setAccessibilityLabel(accessibilityLabel)
for view in launcherViews.dropFirst() {
view.isHidden = true
view.setAccessibilityHidden(true)