feat: add emoji favorites and directional shortcuts
This commit is contained in:
@@ -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) }
|
||||||
|
|||||||
@@ -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) == "👩💻")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user