Files
conjure/Sources/Conjure/EmojiPickerController.swift
T

319 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
actionsButton.target = self
actionsButton.action = #selector(showActions)
let pill = ActionPill(primaryButton: copyButton, actionsButton: actionsButton)
let footer = NSStackView(views: [status, NSView(), pill])
actions.closed = { [weak self] in
self?.copyButton.keyEquivalent = "\r"
self?.focusSearch?()
}
footer.orientation = .horizontal
footer.alignment = .centerY
footer.spacing = 12
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: -8),
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
}
}