Files
conjure/Sources/Conjure/EmojiPickerController.swift
T
2026-10-15 14:03:03 +02:00

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