feat: add emoji favorites and directional shortcuts
This commit is contained in:
@@ -13,7 +13,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed)
|
||||
]
|
||||
private var visibleEntries: [Int] = []
|
||||
private var isCommand = false
|
||||
private var availableEntries = Array(0..<6)
|
||||
private var selectedEntry: Int? = 0
|
||||
private var rowTopConstraints: [NSLayoutConstraint] = []
|
||||
private var heightConstraint: NSLayoutConstraint?
|
||||
@@ -125,19 +125,31 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
|
||||
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 }
|
||||
guard let window = anchor.window, let host = window.contentView else { return }
|
||||
let pill = anchor.superview ?? anchor
|
||||
_ = view
|
||||
applicationLabel.stringValue = application.name
|
||||
isCommand = application.command != nil
|
||||
applicationLabel.stringValue = title
|
||||
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 favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
|
||||
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
|
||||
.foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
|
||||
])
|
||||
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 = ""
|
||||
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
|
||||
transitionGeneration += 1
|
||||
@@ -204,7 +216,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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)
|
||||
}
|
||||
selectedEntry = visibleEntries.first
|
||||
@@ -235,6 +247,10 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||
handle(selector)
|
||||
}
|
||||
|
||||
func handle(_ selector: Selector) -> Bool {
|
||||
switch selector {
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
if let selectedEntry { chooseAction(at: selectedEntry) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
else if let picker = self.emojiPicker { picker.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.canUseNumberShortcuts = { [weak self] in
|
||||
guard let self else { return false }
|
||||
@@ -806,6 +807,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let tool = EmojiPickerController()
|
||||
emojiPicker = tool
|
||||
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() }
|
||||
controller = tool
|
||||
placeholder = "Search emoji by name or :shortcode:…"
|
||||
@@ -1101,6 +1103,7 @@ final class LauncherPanel: NSPanel {
|
||||
var canUseNumberShortcuts: (() -> Bool)?
|
||||
var numberHintsChanged: (() -> Void)?
|
||||
var launchNumber: ((Int) -> Void)?
|
||||
var commandNavigation: ((String) -> Bool)?
|
||||
private var numberShortcuts = CommandNumberShortcuts()
|
||||
private var commandHoldTask: Task<Void, Never>?
|
||||
var numberHintsVisible: Bool { numberShortcuts.isVisible }
|
||||
@@ -1201,6 +1204,7 @@ final class LauncherPanel: NSPanel {
|
||||
}
|
||||
if event.type == .keyDown, attachedSheet == nil,
|
||||
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
|
||||
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
|
||||
switch event.charactersIgnoringModifiers?.lowercased() {
|
||||
case "j":
|
||||
navigateSelection?(1)
|
||||
|
||||
Reference in New Issue
Block a user