27c203b336
The 32-point viewport fade covered launcher rows with only 16 points of inset and emoji with only 12. Match their top spacing to the shared fade height while keeping the viewport mask fixed during scrolling.
304 lines
14 KiB
Swift
304 lines
14 KiB
Swift
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 Emoji", 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 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(copySelection)
|
||
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?.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(_:)): 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
|
||
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() {
|
||
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 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?.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?() }
|
||
}
|