Files
conjure/Sources/Conjure/EmojiPickerController.swift
T

408 lines
18 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, 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 var themeObservation: ThemeObservation?
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 = 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),
])
themeObservation = ThemeObservation { [weak self] in
self?.category.contentTintColor = ThemePalette.secondaryText
self?.empty.textColor = ThemePalette.secondaryText
self?.status.textColor = ThemePalette.secondaryText
}
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: "")
private var themeObservation: ThemeObservation?
private var title = ""
private var count = 0
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),
])
themeObservation = ThemeObservation { [weak self] in
guard let self else { return }
self.setTitle(self.title, count: self.count)
}
}
required init?(coder: NSCoder) { fatalError("Conjure creates emoji headers programmatically") }
func setTitle(_ title: String, count: Int) {
self.title = title
self.count = count
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.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?
private var themeObservation: ThemeObservation?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
themeObservation = ThemeObservation { [weak self] in self?.updateBorder() }
}
required init?(coder: NSCoder) { fatalError("Conjure creates emoji buttons programmatically") }
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?.backgroundColor = (selected ? ThemePalette.selectionFill : ThemePalette.text.withAlphaComponent(0.045)).cgColor
layer?.borderWidth = selected || hovered ? 0.5 : 0
layer?.borderColor = (selected ? ThemePalette.accent.withAlphaComponent(0.8)
: ThemePalette.secondaryText.withAlphaComponent(0.35)).cgColor
}
}