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: 56) layout.minimumInteritemSpacing = 6 layout.minimumLineSpacing = 6 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 { closed?() } } func close() { actions.close() closed?() } override func cancelOperation(_ sender: Any?) { goBack() } 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: .nearestVerticalEdge) } private var columns: Int { max(1, Int((collection.bounds.width - 32 + 6) / 70)) } 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() 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 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") closed?() 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 = 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?() } }