diff --git a/README.md b/README.md index 41c0e5f..5003838 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,11 @@ Currency and crypto conversions require internet access and use [currency-api](h ## 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. diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index 3075515..07ec9a7 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -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) } diff --git a/Sources/Conjure/EmojiPickerController.swift b/Sources/Conjure/EmojiPickerController.swift index 95eff35..6f03627 100644 --- a/Sources/Conjure/EmojiPickerController.swift +++ b/Sources/Conjure/EmojiPickerController.swift @@ -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 diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 1191baa..df290e8 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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? 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) diff --git a/Tests/ConjureKitTests/EmojiPickerTests.swift b/Tests/ConjureKitTests/EmojiPickerTests.swift index 6ad5a5f..b63f683 100644 --- a/Tests/ConjureKitTests/EmojiPickerTests.swift +++ b/Tests/ConjureKitTests/EmojiPickerTests.swift @@ -67,3 +67,45 @@ import Testing let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first) #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) == "👩‍💻") +}