feat: add emoji favorites and directional shortcuts

This commit is contained in:
Valdemar Lorenzen
2026-10-15 14:30:50 +02:00
parent 1de7983345
commit 6cb7ad8479
5 changed files with 194 additions and 23 deletions
+123 -16
View File
@@ -2,12 +2,16 @@ import AppKit
import ConjureKit
@MainActor
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource {
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] = []
@@ -15,6 +19,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
var input: (() -> String)?
var closed: (() -> Void)?
var copied: (() -> Void)?
var focusSearch: (() -> Void)?
init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
self.defaults = defaults
@@ -38,6 +43,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
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()
@@ -58,7 +64,27 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
copyButton.isBordered = false
copyButton.font = .systemFont(ofSize: 12, weight: .medium)
copyButton.contentTintColor = SunsetPalette.text
let footer = NSStackView(views: [status, NSView(), copyButton, ShortcutIndicator("↵")])
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
@@ -82,10 +108,13 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
}
@objc private func categoryChanged() { inputChanged() }
@objc func goBack() { closed?() }
@objc func goBack() {
if actions.isShown { actions.close() } else { closed?() }
}
override func cancelOperation(_ sender: Any?) { goBack() }
func inputChanged() {
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 {
@@ -97,7 +126,11 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
} else {
results = matches
}
selected = 0
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()
@@ -107,18 +140,82 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
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)
(collection.item(at: indexPath(for: old)) as? EmojiItem)?.highlight(false)
updateSelection()
collection.scrollToItems(at: [IndexPath(item: selected, section: 0)], scrollPosition: .nearestVerticalEdge)
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 {
let columns = max(1, Int((collection.bounds.width - 32 + 6) / 70))
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(_:)): moveSelection(by: -columns)
case #selector(NSResponder.moveDown(_:)): moveSelection(by: columns)
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
@@ -132,8 +229,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
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(item: selected, section: 0)) as? EmojiItem)?.highlight(true)
(collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true)
}
@objc func copySelection() {
@@ -151,17 +249,26 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
copied?()
}
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { results.count }
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 : 12, 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 emoji = results[indexPath.item]
let index = index(for: indexPath)
let emoji = results[index]
item.button.title = emoji.value
item.button.toolTip = emoji.name
item.button.setAccessibilityLabel(emoji.name)
item.highlight(indexPath.item == selected)
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 = indexPath.item
self?.selected = index
self?.copySelection()
}
return item