401 lines
18 KiB
Swift
401 lines
18 KiB
Swift
import AppKit
|
||
import ConjureKit
|
||
|
||
@MainActor
|
||
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, NSCollectionViewDelegateFlowLayout {
|
||
private let collection = NSCollectionView()
|
||
private let layout = NSCollectionViewFlowLayout()
|
||
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 sections: [(title: String, range: Range<Int>)] = []
|
||
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)
|
||
category.font = .systemFont(ofSize: 12)
|
||
category.isBordered = false
|
||
category.contentTintColor = SunsetPalette.secondaryText
|
||
category.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
layout.itemSize = NSSize(width: 80, height: 80)
|
||
layout.minimumInteritemSpacing = 8
|
||
layout.minimumLineSpacing = 8
|
||
collection.collectionViewLayout = layout
|
||
collection.backgroundColors = [.clear]
|
||
collection.dataSource = self
|
||
collection.delegate = self
|
||
collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji"))
|
||
collection.register(EmojiSectionHeader.self,
|
||
forSupplementaryViewOfKind: NSCollectionView.elementKindSectionHeader,
|
||
withIdentifier: NSUserInterfaceItemIdentifier("section"))
|
||
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: [category, status, NSView(), pill])
|
||
actions.closed = { [weak self] in
|
||
self?.copyButton.keyEquivalent = "\r"
|
||
self?.focusSearch?()
|
||
}
|
||
footer.orientation = .horizontal
|
||
footer.alignment = .centerY
|
||
footer.spacing = 12
|
||
let headerDivider = NSView()
|
||
let footerDivider = NSView()
|
||
for divider in [headerDivider, footerDivider] {
|
||
divider.wantsLayer = true
|
||
divider.layer?.backgroundColor = SunsetPalette.selectionBorder.cgColor
|
||
divider.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(divider)
|
||
}
|
||
for child in [scroll, empty, footer] {
|
||
child.translatesAutoresizingMaskIntoConstraints = false
|
||
view.addSubview(child)
|
||
}
|
||
NSLayoutConstraint.activate([
|
||
headerDivider.topAnchor.constraint(equalTo: view.topAnchor),
|
||
headerDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
headerDivider.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
headerDivider.heightAnchor.constraint(equalToConstant: 0.5),
|
||
scroll.topAnchor.constraint(equalTo: headerDivider.bottomAnchor),
|
||
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
scroll.bottomAnchor.constraint(equalTo: footerDivider.topAnchor),
|
||
footerDivider.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8),
|
||
footerDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||
footerDivider.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||
footerDivider.heightAnchor.constraint(equalToConstant: 0.5),
|
||
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 query = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let recent = defaults.stringArray(forKey: "recentEmoji") ?? []
|
||
let favorites = defaults.stringArray(forKey: "favoriteEmoji") ?? []
|
||
let favoriteSet = Set(favorites)
|
||
var matches = EmojiCatalog.search(query, category: category.indexOfSelectedItem > 1 ? category.titleOfSelectedItem : nil)
|
||
results.removeAll(keepingCapacity: true)
|
||
sections.removeAll(keepingCapacity: true)
|
||
if category.indexOfSelectedItem == 1 {
|
||
let byValue = Dictionary(uniqueKeysWithValues: matches.map { ($0.value, $0) })
|
||
matches = recent.compactMap { byValue[$0] }
|
||
}
|
||
if !query.isEmpty || category.indexOfSelectedItem > 0 {
|
||
addSection(query.isEmpty ? category.titleOfSelectedItem! : "Search Results",
|
||
emoji: matches.filter { favoriteSet.contains($0.value) } + matches.filter { !favoriteSet.contains($0.value) })
|
||
} else {
|
||
let byValue = Dictionary(uniqueKeysWithValues: matches.map { ($0.value, $0) })
|
||
addSection("Favorites", emoji: favorites.compactMap { byValue[$0] })
|
||
addSection("Recently Used", emoji: recent.compactMap { favoriteSet.contains($0) ? nil : byValue[$0] })
|
||
let promoted = favoriteSet.union(recent)
|
||
let categories = Dictionary(grouping: matches.filter { !promoted.contains($0.value) }, by: \.category)
|
||
for title in EmojiCatalog.categories {
|
||
addSection(title, emoji: categories[title] ?? [])
|
||
}
|
||
}
|
||
selected = previous.flatMap { value in results.firstIndex { $0.value == value } } ?? 0
|
||
collection.reloadData()
|
||
collection.scroll(NSPoint.zero)
|
||
updateSelection()
|
||
}
|
||
|
||
private func addSection(_ title: String, emoji: [Emoji]) {
|
||
guard !emoji.isEmpty else { return }
|
||
let start = results.count
|
||
results.append(contentsOf: emoji)
|
||
sections.append((title, start..<results.count))
|
||
}
|
||
|
||
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 + layout.minimumInteritemSpacing)
|
||
/ (layout.itemSize.width + layout.minimumInteritemSpacing)))
|
||
}
|
||
|
||
private func indexPath(for index: Int) -> IndexPath {
|
||
let section = sections.firstIndex { $0.range.contains(index) }!
|
||
return IndexPath(item: index - sections[section].range.lowerBound, section: section)
|
||
}
|
||
|
||
private func index(for path: IndexPath) -> Int { sections[path.section].range.lowerBound + path.item }
|
||
|
||
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 = sections[path.section - 1].range.count
|
||
target = IndexPath(item: min(((previousCount - 1) / columns) * columns + column, previousCount - 1), section: path.section - 1)
|
||
}
|
||
} else if path.item / columns < (count - 1) / columns {
|
||
target = IndexPath(item: min(path.item + columns, count - 1), section: path.section)
|
||
} else if path.section + 1 < sections.count {
|
||
target = IndexPath(item: min(column, sections[path.section + 1].range.count - 1), section: path.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"
|
||
if !results.isEmpty {
|
||
(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 { sections.count }
|
||
|
||
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
|
||
sections[section].range.count
|
||
}
|
||
|
||
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, insetForSectionAt section: Int) -> NSEdgeInsets {
|
||
NSEdgeInsets(top: 0, left: 16, bottom: section == sections.count - 1 ? 16 : 0, right: 16)
|
||
}
|
||
|
||
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout,
|
||
referenceSizeForHeaderInSection section: Int) -> NSSize {
|
||
NSSize(width: collectionView.bounds.width, height: section == 0 ? LauncherScrollView.fadeHeight + 24 : 40)
|
||
}
|
||
|
||
func collectionView(_ collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind,
|
||
at indexPath: IndexPath) -> NSView {
|
||
let header = collectionView.makeSupplementaryView(ofKind: kind,
|
||
withIdentifier: NSUserInterfaceItemIdentifier("section"), for: indexPath) as! EmojiSectionHeader
|
||
let section = sections[indexPath.section]
|
||
header.setTitle(section.title, count: section.range.count)
|
||
return header
|
||
}
|
||
|
||
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 EmojiSectionHeader: NSView {
|
||
private let label = NSTextField(labelWithString: "")
|
||
|
||
override init(frame frameRect: NSRect) {
|
||
super.init(frame: frameRect)
|
||
label.translatesAutoresizingMaskIntoConstraints = false
|
||
addSubview(label)
|
||
NSLayoutConstraint.activate([
|
||
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
||
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
||
])
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("Conjure creates emoji headers programmatically") }
|
||
|
||
func setTitle(_ title: String, count: Int) {
|
||
let text = NSMutableAttributedString(string: title, attributes: [
|
||
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: SunsetPalette.secondaryText,
|
||
])
|
||
text.append(NSAttributedString(string: " \(count)", attributes: [
|
||
.font: NSFont.systemFont(ofSize: 12), .foregroundColor: SunsetPalette.secondaryText.withAlphaComponent(0.55),
|
||
]))
|
||
label.attributedStringValue = text
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class EmojiItem: NSCollectionViewItem {
|
||
let button = EmojiButton()
|
||
var clicked: (() -> Void)?
|
||
|
||
override func loadView() {
|
||
button.font = .systemFont(ofSize: 28)
|
||
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.selectionFill : SunsetPalette.text.withAlphaComponent(0.045)).cgColor
|
||
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
|
||
layer?.borderColor = (selected ? SunsetPalette.accent.withAlphaComponent(0.8)
|
||
: SunsetPalette.secondaryText.withAlphaComponent(0.35)).cgColor
|
||
}
|
||
}
|