193 lines
8.5 KiB
Swift
193 lines
8.5 KiB
Swift
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?() }
|
||
}
|