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?() } }