334 lines
15 KiB
Swift
334 lines
15 KiB
Swift
import AppKit
|
||
import ConjureKit
|
||
|
||
@MainActor
|
||
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, NSCollectionViewDelegateFlowLayout {
|
||
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 and Paste", target: nil, action: nil)
|
||
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
||
private let actions = ApplicationActionsController()
|
||
private var favoriteCount = 0
|
||
private var splitFavorites: Bool { favoriteCount > 0 && favoriteCount < results.count }
|
||
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)?
|
||
var pasteRequested: (() -> Void)?
|
||
var focusSearch: (() -> 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: 64)
|
||
layout.minimumInteritemSpacing = 1
|
||
layout.minimumLineSpacing = 1
|
||
layout.sectionInset = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 16, bottom: 12, right: 16)
|
||
collection.collectionViewLayout = layout
|
||
collection.backgroundColors = [.clear]
|
||
collection.dataSource = self
|
||
collection.delegate = 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(copyAndPasteSelection)
|
||
copyButton.keyEquivalent = "\r"
|
||
copyButton.isBordered = false
|
||
copyButton.font = .systemFont(ofSize: 12, weight: .medium)
|
||
copyButton.contentTintColor = SunsetPalette.text
|
||
actionsButton.target = self
|
||
actionsButton.action = #selector(showActions)
|
||
actionsButton.keyEquivalent = "k"
|
||
actionsButton.keyEquivalentModifierMask = .command
|
||
actionsButton.isBordered = false
|
||
actionsButton.font = copyButton.font
|
||
actionsButton.contentTintColor = SunsetPalette.text
|
||
let pill = NSStackView(views: [copyButton, ShortcutIndicator("↵"), actionsButton, ShortcutIndicator("⌘", "K")])
|
||
pill.spacing = 8
|
||
pill.edgeInsets = NSEdgeInsets(top: 0, left: 14, bottom: 0, right: 12)
|
||
pill.wantsLayer = true
|
||
pill.layer?.cornerRadius = 18
|
||
pill.layer?.borderWidth = 0.5
|
||
pill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
|
||
pill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
|
||
let footer = NSStackView(views: [status, NSView(), pill])
|
||
pill.heightAnchor.constraint(equalToConstant: 36).isActive = true
|
||
actions.closed = { [weak self] in
|
||
self?.copyButton.keyEquivalent = "\r"
|
||
self?.focusSearch?()
|
||
}
|
||
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() {
|
||
if actions.isShown { actions.close() } else { close() }
|
||
}
|
||
func close() {
|
||
actions.close(animated: false)
|
||
closed?()
|
||
}
|
||
|
||
func inputChanged(preservingSelection: Bool = false) {
|
||
let previous = preservingSelection && results.indices.contains(selected) ? results[selected].value : nil
|
||
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
|
||
}
|
||
let favorites = Set(defaults.stringArray(forKey: "favoriteEmoji") ?? [])
|
||
let favored = results.filter { favorites.contains($0.value) }
|
||
results = favored + results.filter { !favorites.contains($0.value) }
|
||
favoriteCount = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? favored.count : 0
|
||
selected = previous.flatMap { value in results.firstIndex { $0.value == value } } ?? 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(for: old)) as? EmojiItem)?.highlight(false)
|
||
updateSelection()
|
||
collection.scrollToItems(at: [indexPath(for: selected)], scrollPosition: .nearestHorizontalEdge)
|
||
}
|
||
|
||
private var columns: Int { max(1, Int((collection.bounds.width - 32 + 1) / 65)) }
|
||
|
||
private func indexPath(for index: Int) -> IndexPath {
|
||
splitFavorites && index >= favoriteCount
|
||
? IndexPath(item: index - favoriteCount, section: 1) : IndexPath(item: index, section: 0)
|
||
}
|
||
|
||
private func index(for path: IndexPath) -> Int { path.item + (path.section == 1 ? favoriteCount : 0) }
|
||
|
||
private func moveVertically(_ direction: Int) {
|
||
guard !results.isEmpty else { return }
|
||
let path = indexPath(for: selected)
|
||
let count = collectionView(collection, numberOfItemsInSection: path.section)
|
||
let column = path.item % columns
|
||
var target = path
|
||
if direction < 0 {
|
||
if path.item >= columns { target = IndexPath(item: path.item - columns, section: path.section) }
|
||
else if path.section > 0 {
|
||
let previousCount = favoriteCount
|
||
target = IndexPath(item: min(((previousCount - 1) / columns) * columns + column, previousCount - 1), section: 0)
|
||
}
|
||
} else if path.item / columns < (count - 1) / columns {
|
||
target = IndexPath(item: min(path.item + columns, count - 1), section: path.section)
|
||
} else if splitFavorites && path.section == 0 {
|
||
target = IndexPath(item: min(column, results.count - favoriteCount - 1), section: 1)
|
||
}
|
||
moveSelection(by: index(for: target) - selected)
|
||
}
|
||
|
||
func handleControlKey(_ key: String) -> Bool {
|
||
if actions.isShown {
|
||
guard key == "j" || key == "k" else { return false }
|
||
actions.moveSelection(by: key == "j" ? 1 : -1)
|
||
return true
|
||
}
|
||
switch key {
|
||
case "h": moveSelection(by: -1)
|
||
case "j": moveVertically(1)
|
||
case "k": moveVertically(-1)
|
||
case "l": moveSelection(by: 1)
|
||
default: return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
@objc func showActions() {
|
||
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?.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"
|
||
}
|
||
|
||
func toggleFavorite() {
|
||
guard results.indices.contains(selected) else { return }
|
||
let value = results[selected].value
|
||
var favorites = defaults.stringArray(forKey: "favoriteEmoji") ?? []
|
||
if favorites.contains(value) { favorites.removeAll { $0 == value } }
|
||
else { favorites.append(value) }
|
||
defaults.set(favorites, forKey: "favoriteEmoji")
|
||
inputChanged(preservingSelection: true)
|
||
}
|
||
func handle(_ selector: Selector) -> Bool {
|
||
if actions.isShown { return actions.handle(selector) }
|
||
switch selector {
|
||
case #selector(NSResponder.moveLeft(_:)): moveSelection(by: -1)
|
||
case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1)
|
||
case #selector(NSResponder.moveUp(_:)): moveVertically(-1)
|
||
case #selector(NSResponder.moveDown(_:)): moveVertically(1)
|
||
case #selector(NSResponder.insertNewline(_:)): copyAndPasteSelection()
|
||
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
|
||
actionsButton.isEnabled = !results.isEmpty
|
||
status.stringValue = results.isEmpty ? "0 emoji" : "\(results[selected].name) · \(results.count) emoji"
|
||
(collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true)
|
||
}
|
||
|
||
@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()
|
||
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")
|
||
actions.close(animated: false)
|
||
if pasting { pasteRequested?() } else { copied?() }
|
||
}
|
||
|
||
func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 }
|
||
|
||
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
|
||
splitFavorites ? (section == 0 ? favoriteCount : results.count - favoriteCount) : results.count
|
||
}
|
||
|
||
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, insetForSectionAt section: Int) -> NSEdgeInsets {
|
||
NSEdgeInsets(top: section == 1 ? 18 : LauncherScrollView.fadeHeight, left: 16, bottom: splitFavorites && section == 0 ? 0 : 12, right: 16)
|
||
}
|
||
|
||
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
|
||
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("emoji"), for: indexPath) as! EmojiItem
|
||
let index = index(for: indexPath)
|
||
let emoji = results[index]
|
||
item.button.title = emoji.value
|
||
item.button.toolTip = emoji.name
|
||
item.button.setAccessibilityLabel((defaults.stringArray(forKey: "favoriteEmoji") ?? []).contains(emoji.value) ? "\(emoji.name), Favorite" : emoji.name)
|
||
item.highlight(index == selected)
|
||
item.clicked = { [weak self] in
|
||
self?.selected = index
|
||
self?.copyAndPasteSelection()
|
||
}
|
||
return item
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class EmojiItem: NSCollectionViewItem {
|
||
let button = EmojiButton()
|
||
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
|
||
button.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||
view = button
|
||
}
|
||
|
||
func highlight(_ selected: Bool) {
|
||
button.layer?.backgroundColor = selected ? SunsetPalette.accent.withAlphaComponent(0.22).cgColor : nil
|
||
button.setAccessibilitySelected(selected)
|
||
button.selected = selected
|
||
}
|
||
|
||
@objc private func copyEmoji() { clicked?() }
|
||
}
|
||
|
||
@MainActor
|
||
private final class EmojiButton: NSButton {
|
||
var selected = false { didSet { updateBorder() } }
|
||
private var hovered = false { didSet { updateBorder() } }
|
||
private var hoverTracking: NSTrackingArea?
|
||
|
||
override func updateTrackingAreas() {
|
||
super.updateTrackingAreas()
|
||
if let hoverTracking { removeTrackingArea(hoverTracking) }
|
||
let tracking = NSTrackingArea(rect: .zero,
|
||
options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
|
||
owner: self, userInfo: nil)
|
||
addTrackingArea(tracking)
|
||
hoverTracking = tracking
|
||
hovered = window.map { visibleRect.contains(convert($0.mouseLocationOutsideOfEventStream, from: nil)) } ?? false
|
||
}
|
||
|
||
override func mouseEntered(with event: NSEvent) { hovered = true }
|
||
override func mouseExited(with event: NSEvent) { hovered = false }
|
||
|
||
private func updateBorder() {
|
||
layer?.borderWidth = selected || hovered ? 0.5 : 0
|
||
}
|
||
}
|