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
+4 -2
View File
@@ -77,9 +77,11 @@ Currency and crypto conversions require internet access and use [currency-api](h
## Emoji picker ## Emoji picker
Search for **Conjure Emoji** and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally and appear first when you reopen the picker. Search for **Conjure Emoji** and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally. Favorites appear first, followed by recent selections when browsing All Emoji.
Use the arrow keys to navigate; Control-J/Control-K move one emoji forward/back. Return or clicking an emoji copies it and dismisses Conjure, restoring focus to the previous app. Paste with Command-V; no Accessibility permission is needed. Escape or the Back chevron returns to the launcher and restores your search. Use the arrow keys or Control-H/J/K/L to navigate left/down/up/right. Return or clicking an emoji copies it and dismisses Conjure, restoring focus to the previous app. Paste with Command-V; no Accessibility permission is needed. Escape or the Back chevron returns to the launcher and restores your search.
Open **Actions** with Command-K and choose **Favorite** or **Remove Favorite**. Favorites are saved locally and grouped ahead of other emoji, with a small extra spacing break when browsing without a search. There are no star badges.
The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application. The picker works offline with 1,870 emoji from [gemoji v4.1.0](https://github.com/github/gemoji/tree/v4.1.0). Its [MIT license](Sources/ConjureKit/Resources/LICENSE-gemoji.txt) is included in the application.
@@ -13,7 +13,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed) ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
] ]
private var visibleEntries: [Int] = [] private var visibleEntries: [Int] = []
private var isCommand = false private var availableEntries = Array(0..<6)
private var selectedEntry: Int? = 0 private var selectedEntry: Int? = 0
private var rowTopConstraints: [NSLayoutConstraint] = [] private var rowTopConstraints: [NSLayoutConstraint] = []
private var heightConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint?
@@ -125,19 +125,31 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
func show(for application: Application, relativeTo anchor: NSView) { func show(for application: Application, relativeTo anchor: NSView) {
show(title: application.name, relativeTo: anchor,
available: application.command == nil ? Array(entries.indices) : [0, 1, 3])
}
func showEmoji(named name: String, relativeTo anchor: NSView) {
show(title: name, relativeTo: anchor, available: [0, 1], emoji: true)
}
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false) {
if isShown { close(); return } if isShown { close(); return }
guard let window = anchor.window, let host = window.contentView else { return } guard let window = anchor.window, let host = window.contentView else { return }
let pill = anchor.superview ?? anchor let pill = anchor.superview ?? anchor
_ = view _ = view
applicationLabel.stringValue = application.name applicationLabel.stringValue = title
isCommand = application.command != nil availableEntries = available
entries[0].button.title = emoji ? "Copy Emoji" : "Open"
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.doc" : "arrow.up.forward.app", accessibilityDescription: nil)
search.setAccessibilityLabel(emoji ? "Search emoji actions" : "Search application actions")
let favorite = entries[1] let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite" let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [ favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
.foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) .foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
]) ])
favorite.button.setAccessibilityLabel(favoriteTitle) favorite.button.setAccessibilityLabel(favoriteTitle)
favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil) favorite.icon.image = emoji ? nil : NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil)
search.stringValue = "" search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
transitionGeneration += 1 transitionGeneration += 1
@@ -204,7 +216,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = entries.indices.filter { visibleEntries = entries.indices.filter {
(!isCommand || $0 == 0 || $0 == 1 || $0 == 3) availableEntries.contains($0)
&& (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil) && (query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil)
} }
selectedEntry = visibleEntries.first selectedEntry = visibleEntries.first
@@ -235,6 +247,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
handle(selector)
}
func handle(_ selector: Selector) -> Bool {
switch selector { switch selector {
case #selector(NSResponder.insertNewline(_:)): case #selector(NSResponder.insertNewline(_:)):
if let selectedEntry { chooseAction(at: selectedEntry) } if let selectedEntry { chooseAction(at: selectedEntry) }
+123 -16
View File
@@ -2,12 +2,16 @@ import AppKit
import ConjureKit import ConjureKit
@MainActor @MainActor
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource { final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, NSCollectionViewDelegateFlowLayout {
private let collection = NSCollectionView() private let collection = NSCollectionView()
private let category = NSPopUpButton() private let category = NSPopUpButton()
private let status = NSTextField(labelWithString: "") private let status = NSTextField(labelWithString: "")
private let empty = NSTextField(labelWithString: "No matching emoji") private let empty = NSTextField(labelWithString: "No matching emoji")
private let copyButton = NSButton(title: "Copy Emoji", target: nil, action: nil) 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 defaults: UserDefaults
private let pasteboard: NSPasteboard private let pasteboard: NSPasteboard
private var results: [Emoji] = [] private var results: [Emoji] = []
@@ -15,6 +19,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
var input: (() -> String)? var input: (() -> String)?
var closed: (() -> Void)? var closed: (() -> Void)?
var copied: (() -> Void)? var copied: (() -> Void)?
var focusSearch: (() -> Void)?
init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) { init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
self.defaults = defaults self.defaults = defaults
@@ -38,6 +43,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
collection.collectionViewLayout = layout collection.collectionViewLayout = layout
collection.backgroundColors = [.clear] collection.backgroundColors = [.clear]
collection.dataSource = self collection.dataSource = self
collection.delegate = self
collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji")) collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji"))
collection.setAccessibilityLabel("Emoji grid") collection.setAccessibilityLabel("Emoji grid")
let scroll = LauncherScrollView() let scroll = LauncherScrollView()
@@ -58,7 +64,27 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
copyButton.isBordered = false copyButton.isBordered = false
copyButton.font = .systemFont(ofSize: 12, weight: .medium) copyButton.font = .systemFont(ofSize: 12, weight: .medium)
copyButton.contentTintColor = SunsetPalette.text 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 footer.spacing = 8
for child in [category, scroll, empty, footer] { for child in [category, scroll, empty, footer] {
child.translatesAutoresizingMaskIntoConstraints = false child.translatesAutoresizingMaskIntoConstraints = false
@@ -82,10 +108,13 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
} }
@objc private func categoryChanged() { inputChanged() } @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() } 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 recent = defaults.stringArray(forKey: "recentEmoji") ?? []
let matches = EmojiCatalog.search(input?() ?? "", category: category.indexOfSelectedItem > 1 ? category.titleOfSelectedItem : nil) let matches = EmojiCatalog.search(input?() ?? "", category: category.indexOfSelectedItem > 1 ? category.titleOfSelectedItem : nil)
if category.indexOfSelectedItem == 1 { if category.indexOfSelectedItem == 1 {
@@ -97,7 +126,11 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
} else { } else {
results = matches 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.reloadData()
collection.scroll(NSPoint.zero) collection.scroll(NSPoint.zero)
updateSelection() updateSelection()
@@ -107,18 +140,82 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
guard !results.isEmpty else { return } guard !results.isEmpty else { return }
let old = selected let old = selected
selected = min(max(selected + offset, 0), results.count - 1) 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() 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 { 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 { switch selector {
case #selector(NSResponder.moveLeft(_:)): moveSelection(by: -1) case #selector(NSResponder.moveLeft(_:)): moveSelection(by: -1)
case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1) case #selector(NSResponder.moveRight(_:)): moveSelection(by: 1)
case #selector(NSResponder.moveUp(_:)): moveSelection(by: -columns) case #selector(NSResponder.moveUp(_:)): moveVertically(-1)
case #selector(NSResponder.moveDown(_:)): moveSelection(by: columns) case #selector(NSResponder.moveDown(_:)): moveVertically(1)
case #selector(NSResponder.insertNewline(_:)): copySelection() case #selector(NSResponder.insertNewline(_:)): copySelection()
case #selector(NSResponder.cancelOperation(_:)): goBack() case #selector(NSResponder.cancelOperation(_:)): goBack()
default: return false 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" empty.stringValue = category.indexOfSelectedItem == 1 ? "No recently used emoji match" : "No matching emoji"
} }
copyButton.isEnabled = !results.isEmpty copyButton.isEnabled = !results.isEmpty
actionsButton.isEnabled = !results.isEmpty
status.stringValue = results.isEmpty ? "0 emoji" : "\(results[selected].name) · \(results.count) emoji" 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() { @objc func copySelection() {
@@ -151,17 +249,26 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource
copied?() 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 { func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("emoji"), for: indexPath) as! EmojiItem 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.title = emoji.value
item.button.toolTip = emoji.name item.button.toolTip = emoji.name
item.button.setAccessibilityLabel(emoji.name) item.button.setAccessibilityLabel((defaults.stringArray(forKey: "favoriteEmoji") ?? []).contains(emoji.value) ? "\(emoji.name), Favorite" : emoji.name)
item.highlight(indexPath.item == selected) item.highlight(index == selected)
item.clicked = { [weak self] in item.clicked = { [weak self] in
self?.selected = indexPath.item self?.selected = index
self?.copySelection() self?.copySelection()
} }
return item return item
@@ -66,6 +66,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
else if let picker = self.emojiPicker { picker.moveSelection(by: offset) } else if let picker = self.emojiPicker { picker.moveSelection(by: offset) }
else { self.moveSelection(by: offset) } else { self.moveSelection(by: offset) }
} }
window.commandNavigation = { [weak self] key in self?.emojiPicker?.handleControlKey(key) ?? false }
window.dismiss = { [weak self] in self?.dismiss() } window.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false } guard let self else { return false }
@@ -806,6 +807,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let tool = EmojiPickerController() let tool = EmojiPickerController()
emojiPicker = tool emojiPicker = tool
tool.input = { [weak self] in self?.searchField.stringValue ?? "" } tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
tool.copied = { [weak self] in self?.dismiss() } tool.copied = { [weak self] in self?.dismiss() }
controller = tool controller = tool
placeholder = "Search emoji by name or :shortcode:…" placeholder = "Search emoji by name or :shortcode:…"
@@ -1101,6 +1103,7 @@ final class LauncherPanel: NSPanel {
var canUseNumberShortcuts: (() -> Bool)? var canUseNumberShortcuts: (() -> Bool)?
var numberHintsChanged: (() -> Void)? var numberHintsChanged: (() -> Void)?
var launchNumber: ((Int) -> Void)? var launchNumber: ((Int) -> Void)?
var commandNavigation: ((String) -> Bool)?
private var numberShortcuts = CommandNumberShortcuts() private var numberShortcuts = CommandNumberShortcuts()
private var commandHoldTask: Task<Void, Never>? private var commandHoldTask: Task<Void, Never>?
var numberHintsVisible: Bool { numberShortcuts.isVisible } var numberHintsVisible: Bool { numberShortcuts.isVisible }
@@ -1201,6 +1204,7 @@ final class LauncherPanel: NSPanel {
} }
if event.type == .keyDown, attachedSheet == nil, if event.type == .keyDown, attachedSheet == nil,
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control { event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
switch event.charactersIgnoringModifiers?.lowercased() { switch event.charactersIgnoringModifiers?.lowercased() {
case "j": case "j":
navigateSelection?(1) navigateSelection?(1)
@@ -67,3 +67,45 @@ import Testing
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first) let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first)
#expect(table.numberOfRows == 1) #expect(table.numberOfRows == 1)
} }
@Test @MainActor func emojiFavoritesPersistAndNavigationCrossesTheSpacingBreak() throws {
_ = NSApplication.shared
let suite = "ConjureEmojiFavorites.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
let pasteboard = NSPasteboard.withUniqueName()
defer { defaults.removePersistentDomain(forName: suite); pasteboard.releaseGlobally() }
var query = "👩‍💻"
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
picker.input = { query }
picker.view.frame = NSRect(x: 0, y: 0, width: 750, height: 400)
picker.toggleFavorite()
#expect(defaults.stringArray(forKey: "favoriteEmoji") == ["👩‍💻"])
query = ""
picker.inputChanged()
picker.view.layoutSubtreeIfNeeded()
let grid = try #require(picker.view.subviews.compactMap { $0 as? NSScrollView }.first?.documentView as? NSCollectionView)
#expect(grid.numberOfSections == 2)
#expect(grid.numberOfItems(inSection: 0) == 1)
#expect(picker.handleControlKey("j"))
picker.copySelection()
#expect(pasteboard.string(forType: .string) == EmojiCatalog.all[0].value)
#expect(picker.handleControlKey("k"))
picker.copySelection()
#expect(pasteboard.string(forType: .string) == "👩‍💻")
#expect(picker.handleControlKey("l"))
#expect(picker.handleControlKey("l"))
#expect(picker.handleControlKey("h"))
picker.copySelection()
#expect(pasteboard.string(forType: .string) == EmojiCatalog.all[0].value)
#expect(picker.handleControlKey("k"))
picker.toggleFavorite()
#expect(defaults.stringArray(forKey: "favoriteEmoji") == [])
#expect(grid.numberOfSections == 1)
query = "👩‍💻"
picker.inputChanged()
picker.toggleFavorite()
let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
_ = reopened.view
reopened.copySelection()
#expect(pasteboard.string(forType: .string) == "👩‍💻")
}