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)] = [] 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 = ThemePalette.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 = ThemePalette.secondaryText status.font = .systemFont(ofSize: 12) status.textColor = ThemePalette.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 for child in [scroll, empty, footer] { child.translatesAutoresizingMaskIntoConstraints = false view.addSubview(child) } NSLayoutConstraint.activate([ scroll.topAnchor.constraint(equalTo: view.topAnchor), scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor), scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8), 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.. 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: ThemePalette.secondaryText, ]) text.append(NSAttributedString(string: " \(count)", attributes: [ .font: NSFont.systemFont(ofSize: 12), .foregroundColor: ThemePalette.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 ? ThemePalette.selectionFill : ThemePalette.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 ? ThemePalette.accent.withAlphaComponent(0.8) : ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor } }