feat: add searchable emoji picker
This commit is contained in:
@@ -8,6 +8,7 @@ build:
|
|||||||
mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources
|
mkdir -p $(APP)/Contents/MacOS $(APP)/Contents/Resources
|
||||||
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
|
cp .build/release/Conjure $(APP)/Contents/MacOS/Conjure
|
||||||
cp Resources/Info.plist $(APP)/Contents/Info.plist
|
cp Resources/Info.plist $(APP)/Contents/Info.plist
|
||||||
|
cp -R .build/release/Conjure_ConjureKit.bundle $(APP)/Contents/Resources/
|
||||||
swift Scripts/icon.swift .build/Conjure.iconset
|
swift Scripts/icon.swift .build/Conjure.iconset
|
||||||
iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns
|
iconutil -c icns .build/Conjure.iconset -o $(APP)/Contents/Resources/Conjure.icns
|
||||||
codesign --force --sign - $(APP)
|
codesign --force --sign - $(APP)
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ let package = Package(
|
|||||||
platforms: [.macOS(.v14)],
|
platforms: [.macOS(.v14)],
|
||||||
products: [.executable(name: "Conjure", targets: ["Conjure"])],
|
products: [.executable(name: "Conjure", targets: ["Conjure"])],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "ConjureKit"),
|
.target(name: "ConjureKit", resources: [.process("Resources")]),
|
||||||
.executableTarget(name: "Conjure", dependencies: ["ConjureKit"]),
|
.executableTarget(name: "Conjure", dependencies: ["ConjureKit"]),
|
||||||
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
|
.testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Conjure
|
# Conjure
|
||||||
|
|
||||||
A native macOS launcher for applications, calculations, unit and currency conversions, and English → Japanese translation.
|
A native macOS launcher for applications, calculations, unit and currency conversions, emoji, and English → Japanese translation.
|
||||||
|
|
||||||
## Build and install
|
## Build and install
|
||||||
|
|
||||||
@@ -75,6 +75,14 @@ Convert length, mass, temperature, volume, duration, speed, area, and data with
|
|||||||
|
|
||||||
Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry.
|
Currency and crypto conversions require internet access and use [currency-api](https://github.com/fawazahmed0/exchange-api). These are **daily rates, not live market prices**; the snapshot date is shown with the answer. Treat them as estimates, not trading or payment quotes. Fetch failures show an error rather than silently using old rates; change the query to retry.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search.
|
Search for **Conjure Translate** and open it. Enter English in the search bar, choose a provider, then press Return or click **Translate**. Typing alone never sends text. Copy selected Japanese output with Command-C; the copy hint appears beside the Translate action at the bottom. The Back chevron beside the input or Escape returns to the launcher and restores your search.
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource {
|
||||||
|
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 defaults: UserDefaults
|
||||||
|
private let pasteboard: NSPasteboard
|
||||||
|
private var results: [Emoji] = []
|
||||||
|
private var selected = 0
|
||||||
|
var input: (() -> String)?
|
||||||
|
var closed: (() -> Void)?
|
||||||
|
var copied: (() -> Void)?
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.pasteboard = pasteboard
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) { fatalError("Conjure creates its emoji picker programmatically") }
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = NSView()
|
||||||
|
category.addItems(withTitles: ["All Emoji", "Recently Used"] + EmojiCatalog.categories)
|
||||||
|
category.setAccessibilityLabel("Emoji category")
|
||||||
|
category.target = self
|
||||||
|
category.action = #selector(categoryChanged)
|
||||||
|
let layout = NSCollectionViewFlowLayout()
|
||||||
|
layout.itemSize = NSSize(width: 64, height: 56)
|
||||||
|
layout.minimumInteritemSpacing = 6
|
||||||
|
layout.minimumLineSpacing = 6
|
||||||
|
layout.sectionInset = NSEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
|
||||||
|
collection.collectionViewLayout = layout
|
||||||
|
collection.backgroundColors = [.clear]
|
||||||
|
collection.dataSource = self
|
||||||
|
collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji"))
|
||||||
|
collection.setAccessibilityLabel("Emoji grid")
|
||||||
|
let scroll = LauncherScrollView()
|
||||||
|
scroll.documentView = collection
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.autohidesScrollers = true
|
||||||
|
scroll.scrollerStyle = .overlay
|
||||||
|
scroll.drawsBackground = false
|
||||||
|
scroll.contentView.drawsBackground = false
|
||||||
|
empty.textColor = SunsetPalette.secondaryText
|
||||||
|
status.font = .systemFont(ofSize: 12)
|
||||||
|
status.textColor = SunsetPalette.secondaryText
|
||||||
|
status.lineBreakMode = .byTruncatingTail
|
||||||
|
status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
copyButton.target = self
|
||||||
|
copyButton.action = #selector(copySelection)
|
||||||
|
copyButton.keyEquivalent = "\r"
|
||||||
|
copyButton.isBordered = false
|
||||||
|
copyButton.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
copyButton.contentTintColor = SunsetPalette.text
|
||||||
|
let footer = NSStackView(views: [status, NSView(), copyButton, ShortcutIndicator("↵")])
|
||||||
|
footer.spacing = 8
|
||||||
|
for child in [category, scroll, empty, footer] {
|
||||||
|
child.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(child)
|
||||||
|
}
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
category.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
category.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||||
|
scroll.topAnchor.constraint(equalTo: category.bottomAnchor, constant: 4),
|
||||||
|
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -4),
|
||||||
|
empty.centerXAnchor.constraint(equalTo: scroll.centerXAnchor),
|
||||||
|
empty.centerYAnchor.constraint(equalTo: scroll.centerYAnchor),
|
||||||
|
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||||
|
footer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||||
|
footer.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -8),
|
||||||
|
footer.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
])
|
||||||
|
inputChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func categoryChanged() { inputChanged() }
|
||||||
|
@objc func goBack() { closed?() }
|
||||||
|
override func cancelOperation(_ sender: Any?) { goBack() }
|
||||||
|
|
||||||
|
func inputChanged() {
|
||||||
|
let recent = defaults.stringArray(forKey: "recentEmoji") ?? []
|
||||||
|
let matches = EmojiCatalog.search(input?() ?? "", category: category.indexOfSelectedItem > 1 ? category.titleOfSelectedItem : nil)
|
||||||
|
if category.indexOfSelectedItem == 1 {
|
||||||
|
results = recent.compactMap { value in matches.first { $0.value == value } }
|
||||||
|
} else if category.indexOfSelectedItem == 0, (input?() ?? "").isEmpty {
|
||||||
|
let recentSet = Set(recent)
|
||||||
|
results = recent.compactMap { value in matches.first { $0.value == value } }
|
||||||
|
+ matches.filter { !recentSet.contains($0.value) }
|
||||||
|
} else {
|
||||||
|
results = matches
|
||||||
|
}
|
||||||
|
selected = 0
|
||||||
|
collection.reloadData()
|
||||||
|
collection.scroll(NSPoint.zero)
|
||||||
|
updateSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveSelection(by offset: Int) {
|
||||||
|
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)
|
||||||
|
updateSelection()
|
||||||
|
collection.scrollToItems(at: [IndexPath(item: selected, section: 0)], scrollPosition: .nearestVerticalEdge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(_ selector: Selector) -> Bool {
|
||||||
|
let columns = max(1, Int((collection.bounds.width - 32 + 6) / 70))
|
||||||
|
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.insertNewline(_:)): copySelection()
|
||||||
|
case #selector(NSResponder.cancelOperation(_:)): goBack()
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSelection() {
|
||||||
|
empty.isHidden = !results.isEmpty
|
||||||
|
if results.isEmpty {
|
||||||
|
empty.stringValue = category.indexOfSelectedItem == 1 ? "No recently used emoji match" : "No matching emoji"
|
||||||
|
}
|
||||||
|
copyButton.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func copySelection() {
|
||||||
|
guard results.indices.contains(selected) else { return }
|
||||||
|
let value = results[selected].value
|
||||||
|
pasteboard.clearContents()
|
||||||
|
guard pasteboard.setString(value, forType: .string) else {
|
||||||
|
status.stringValue = "Couldn’t copy emoji. Try again."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var recent = defaults.stringArray(forKey: "recentEmoji") ?? []
|
||||||
|
recent.removeAll { $0 == value }
|
||||||
|
defaults.set(Array(([value] + recent).prefix(30)), forKey: "recentEmoji")
|
||||||
|
closed?()
|
||||||
|
copied?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int { results.count }
|
||||||
|
|
||||||
|
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
|
||||||
|
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier("emoji"), for: indexPath) as! EmojiItem
|
||||||
|
let emoji = results[indexPath.item]
|
||||||
|
item.button.title = emoji.value
|
||||||
|
item.button.toolTip = emoji.name
|
||||||
|
item.button.setAccessibilityLabel(emoji.name)
|
||||||
|
item.highlight(indexPath.item == selected)
|
||||||
|
item.clicked = { [weak self] in
|
||||||
|
self?.selected = indexPath.item
|
||||||
|
self?.copySelection()
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class EmojiItem: NSCollectionViewItem {
|
||||||
|
let button = NSButton()
|
||||||
|
var clicked: (() -> Void)?
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
button.font = .systemFont(ofSize: 32)
|
||||||
|
button.isBordered = false
|
||||||
|
button.target = self
|
||||||
|
button.action = #selector(copyEmoji)
|
||||||
|
button.wantsLayer = true
|
||||||
|
button.layer?.cornerRadius = 10
|
||||||
|
view = button
|
||||||
|
}
|
||||||
|
|
||||||
|
func highlight(_ selected: Bool) {
|
||||||
|
button.layer?.backgroundColor = selected ? SunsetPalette.accent.withAlphaComponent(0.22).cgColor : nil
|
||||||
|
button.setAccessibilitySelected(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func copyEmoji() { clicked?() }
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import ConjureKit
|
|||||||
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||||||
private let searchField = NSSearchField()
|
private let searchField = NSSearchField()
|
||||||
private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
|
private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
|
||||||
private let translationBack = NSButton(title: "", target: nil, action: nil)
|
private let commandBack = NSButton(title: "", target: nil, action: nil)
|
||||||
private let tableView = ApplicationTableView()
|
private let tableView = ApplicationTableView()
|
||||||
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
|
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
|
||||||
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
|
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
|
||||||
@@ -34,6 +34,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private var launcherViews: [NSView] = []
|
private var launcherViews: [NSView] = []
|
||||||
private var launcherFrameBeforeReview: NSRect?
|
private var launcherFrameBeforeReview: NSRect?
|
||||||
private var translation: TranslationController?
|
private var translation: TranslationController?
|
||||||
|
private var emojiPicker: EmojiPickerController?
|
||||||
|
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
|
||||||
|
|
||||||
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
|
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
|
||||||
self.history = history
|
self.history = history
|
||||||
@@ -61,13 +63,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if let review = self.uninstallReview { review.moveSelection(offset) }
|
if let review = self.uninstallReview { review.moveSelection(offset) }
|
||||||
else if self.actions.isShown { self.actions.moveSelection(by: offset) }
|
else if self.actions.isShown { self.actions.moveSelection(by: offset) }
|
||||||
|
else if let picker = self.emojiPicker { picker.moveSelection(by: offset) }
|
||||||
else { self.moveSelection(by: offset) }
|
else { self.moveSelection(by: offset) }
|
||||||
}
|
}
|
||||||
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 }
|
||||||
return !self.isLaunching && self.calculation == nil && self.resultCount > 0
|
return !self.isLaunching && self.calculation == nil && self.resultCount > 0
|
||||||
&& self.launcherFrameBeforeReview == nil && !self.actions.isShown && self.translation == nil
|
&& self.launcherFrameBeforeReview == nil && !self.actions.isShown && !self.isCommandOpen
|
||||||
}
|
}
|
||||||
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
|
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
|
||||||
window.launchNumber = { [weak self] index in
|
window.launchNumber = { [weak self] index in
|
||||||
@@ -98,7 +101,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
func present() {
|
func present() {
|
||||||
(window as? LauncherPanel)?.beginNumberHintSession()
|
(window as? LauncherPanel)?.beginNumberHintSession()
|
||||||
if launcherFrameBeforeReview != nil || translation != nil {
|
if launcherFrameBeforeReview != nil || isCommandOpen {
|
||||||
showWindow(nil)
|
showWindow(nil)
|
||||||
NSApp.activate()
|
NSApp.activate()
|
||||||
window?.makeKeyAndOrderFront(nil)
|
window?.makeKeyAndOrderFront(nil)
|
||||||
@@ -134,8 +137,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
func dismiss(restoringFocus: Bool = true) {
|
func dismiss(restoringFocus: Bool = true) {
|
||||||
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
||||||
if let review = uninstallReview { review.goBack(); return }
|
if let review = uninstallReview { review.goBack(); return }
|
||||||
if let translation {
|
if isCommandOpen {
|
||||||
translation.goBack()
|
goBackCommand()
|
||||||
if restoringFocus { return }
|
if restoringFocus { return }
|
||||||
}
|
}
|
||||||
guard launcherFrameBeforeReview == nil else { return }
|
guard launcherFrameBeforeReview == nil else { return }
|
||||||
@@ -230,18 +233,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
||||||
searchSymbol.contentTintColor = SunsetPalette.accent
|
searchSymbol.contentTintColor = SunsetPalette.accent
|
||||||
translationBack.image = IconStyle.symbol("chevron.left", for: searchFont)
|
commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
|
||||||
translationBack.imageScaling = .scaleProportionallyDown
|
commandBack.imageScaling = .scaleProportionallyDown
|
||||||
translationBack.isBordered = false
|
commandBack.isBordered = false
|
||||||
translationBack.contentTintColor = SunsetPalette.secondaryText
|
commandBack.contentTintColor = SunsetPalette.secondaryText
|
||||||
translationBack.setAccessibilityLabel("Back to applications")
|
commandBack.setAccessibilityLabel("Back to applications")
|
||||||
translationBack.toolTip = "Back to applications (Escape)"
|
commandBack.toolTip = "Back to applications (Escape)"
|
||||||
translationBack.isHidden = true
|
commandBack.isHidden = true
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
translationBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||||||
translationBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||||||
])
|
])
|
||||||
let header = NSStackView(views: [searchSymbol, translationBack, searchField])
|
let header = NSStackView(views: [searchSymbol, commandBack, searchField])
|
||||||
header.orientation = .horizontal
|
header.orientation = .horizontal
|
||||||
header.alignment = .centerY
|
header.alignment = .centerY
|
||||||
header.spacing = 12
|
header.spacing = 12
|
||||||
@@ -431,7 +434,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
|
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
|
||||||
guard translation == nil else { return }
|
guard !isCommandOpen else { return }
|
||||||
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
||||||
? results[tableView.selectedRow].id : nil
|
? results[tableView.selectedRow].id : nil
|
||||||
let query = searchField.stringValue
|
let query = searchField.stringValue
|
||||||
@@ -497,10 +500,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
func controlTextDidChange(_ notification: Notification) {
|
func controlTextDidChange(_ notification: Notification) {
|
||||||
if let translation { translation.inputChanged(); return }
|
if let translation { translation.inputChanged(); return }
|
||||||
|
if let emojiPicker { emojiPicker.inputChanged(); return }
|
||||||
updateResults()
|
updateResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||||
|
if let emojiPicker { return emojiPicker.handle(commandSelector) }
|
||||||
if let translation {
|
if let translation {
|
||||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true }
|
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true }
|
||||||
if commandSelector == #selector(NSResponder.cancelOperation(_:)) { translation.goBack(); return true }
|
if commandSelector == #selector(NSResponder.cancelOperation(_:)) { translation.goBack(); return true }
|
||||||
@@ -524,7 +529,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private var resultCount: Int { calculation == nil ? results.count : 1 }
|
private var resultCount: Int { calculation == nil ? results.count : 1 }
|
||||||
|
|
||||||
private func moveSelection(by offset: Int) {
|
private func moveSelection(by offset: Int) {
|
||||||
guard translation == nil else { return }
|
guard !isCommandOpen else { return }
|
||||||
guard resultCount > 0 else { return }
|
guard resultCount > 0 else { return }
|
||||||
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
|
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
|
||||||
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||||||
@@ -532,7 +537,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateOpenButton() {
|
private func updateOpenButton() {
|
||||||
let canAct = !isLaunching && launcherFrameBeforeReview == nil && translation == nil && tableView.selectedRow >= 0
|
let canAct = !isLaunching && launcherFrameBeforeReview == nil && !isCommandOpen && tableView.selectedRow >= 0
|
||||||
openButton.title = calculation == nil
|
openButton.title = calculation == nil
|
||||||
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
|
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
|
||||||
? "Open Command" : "Open Application") : "Copy Answer"
|
? "Open Command" : "Open Application") : "Copy Answer"
|
||||||
@@ -584,9 +589,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
cell.locationLabel.isHidden = true
|
cell.locationLabel.isHidden = true
|
||||||
cell.kindLabel.attributedStringValue = history.isFavorite(entry)
|
cell.kindLabel.attributedStringValue = history.isFavorite(entry)
|
||||||
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
|
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
|
||||||
cell.imageView?.image = NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
|
cell.imageView?.image = NSImage(systemSymbolName: entry.command == .emoji ? "face.smiling" : "character.bubble", accessibilityDescription: nil)
|
||||||
cell.imageView?.contentTintColor = SunsetPalette.accent
|
cell.imageView?.contentTintColor = SunsetPalette.accent
|
||||||
cell.toolTip = "Translate English to Japanese"
|
cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
|
||||||
} else {
|
} else {
|
||||||
let application = results[row]
|
let application = results[row]
|
||||||
cell.textField?.stringValue = application.name
|
cell.textField?.stringValue = application.name
|
||||||
@@ -616,7 +621,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
|
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
|
||||||
let visible = (window as? LauncherPanel)?.numberHintsVisible == true
|
let visible = (window as? LauncherPanel)?.numberHintsVisible == true
|
||||||
&& calculation == nil && row < 9 && row < resultCount && translation == nil
|
&& calculation == nil && row < 9 && row < resultCount && !isCommandOpen
|
||||||
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
|
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
|
||||||
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
|
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
|
||||||
}
|
}
|
||||||
@@ -639,7 +644,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
|
|
||||||
@objc private func showActions() {
|
@objc private func showActions() {
|
||||||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
||||||
guard translation == nil else { return }
|
guard !isCommandOpen else { return }
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherPanel)?.resetNumberHints()
|
||||||
let application = results[tableView.selectedRow]
|
let application = results[tableView.selectedRow]
|
||||||
actions.open = { [weak self] in self?.openApplication(application) }
|
actions.open = { [weak self] in self?.openApplication(application) }
|
||||||
@@ -727,7 +732,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openSelection() {
|
@objc private func openSelection() {
|
||||||
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||||||
if let calculation {
|
if let calculation {
|
||||||
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
|
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
|
||||||
let pasteboard = NSPasteboard.general
|
let pasteboard = NSPasteboard.general
|
||||||
@@ -753,11 +758,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func openApplication(_ application: Application) {
|
private func openApplication(_ application: Application) {
|
||||||
guard !isLaunching, translation == nil, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||||||
if let command = application.command {
|
if let command = application.command {
|
||||||
switch command {
|
showCommand(command)
|
||||||
case .translate: showTranslation()
|
|
||||||
}
|
|
||||||
history.recordLaunch(of: application)
|
history.recordLaunch(of: application)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -781,19 +784,42 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showTranslation() {
|
@objc private func goBackCommand() {
|
||||||
guard translation == nil, let content = window?.contentView else { return }
|
translation?.goBack()
|
||||||
let controller = TranslationController()
|
emojiPicker?.goBack()
|
||||||
translation = controller
|
}
|
||||||
controller.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
|
||||||
|
private func showCommand(_ command: Application.Command) {
|
||||||
|
guard !isCommandOpen, let content = window?.contentView else { return }
|
||||||
|
let controller: NSViewController
|
||||||
|
let placeholder: String
|
||||||
|
let accessibilityLabel: String
|
||||||
|
switch command {
|
||||||
|
case .translate:
|
||||||
|
let tool = TranslationController()
|
||||||
|
translation = tool
|
||||||
|
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
||||||
|
controller = tool
|
||||||
|
placeholder = "Enter English text to translate…"
|
||||||
|
accessibilityLabel = "English text to translate into Japanese"
|
||||||
|
case .emoji:
|
||||||
|
let tool = EmojiPickerController()
|
||||||
|
emojiPicker = tool
|
||||||
|
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
||||||
|
tool.copied = { [weak self] in self?.dismiss() }
|
||||||
|
controller = tool
|
||||||
|
placeholder = "Search emoji by name or :shortcode:…"
|
||||||
|
accessibilityLabel = "Search emoji"
|
||||||
|
}
|
||||||
let previousQuery = searchField.stringValue
|
let previousQuery = searchField.stringValue
|
||||||
let previousPlaceholder = searchField.placeholderAttributedString
|
let previousPlaceholder = searchField.placeholderAttributedString
|
||||||
controller.closed = { [weak self, weak controller] in
|
let close: () -> Void = { [weak self, weak controller] in
|
||||||
guard let self, let controller else { return }
|
guard let self, let controller else { return }
|
||||||
controller.view.removeFromSuperview()
|
controller.view.removeFromSuperview()
|
||||||
self.translation = nil
|
self.translation = nil
|
||||||
self.translationBack.isHidden = true
|
self.emojiPicker = nil
|
||||||
self.translationBack.target = nil
|
self.commandBack.isHidden = true
|
||||||
|
self.commandBack.target = nil
|
||||||
self.searchSymbol.isHidden = false
|
self.searchSymbol.isHidden = false
|
||||||
for view in self.launcherViews.dropFirst() {
|
for view in self.launcherViews.dropFirst() {
|
||||||
view.isHidden = false
|
view.isHidden = false
|
||||||
@@ -807,19 +833,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
self.updateResults()
|
self.updateResults()
|
||||||
self.window?.makeFirstResponder(self.searchField)
|
self.window?.makeFirstResponder(self.searchField)
|
||||||
}
|
}
|
||||||
|
translation?.closed = close
|
||||||
|
emojiPicker?.closed = close
|
||||||
actions.close()
|
actions.close()
|
||||||
(window as? LauncherPanel)?.resetNumberHints()
|
(window as? LauncherPanel)?.resetNumberHints()
|
||||||
openButton.keyEquivalent = ""
|
openButton.keyEquivalent = ""
|
||||||
searchSymbol.isHidden = true
|
searchSymbol.isHidden = true
|
||||||
translationBack.target = controller
|
commandBack.target = self
|
||||||
translationBack.action = #selector(TranslationController.goBack)
|
commandBack.action = #selector(goBackCommand)
|
||||||
translationBack.isHidden = false
|
commandBack.isHidden = false
|
||||||
searchField.stringValue = ""
|
searchField.stringValue = ""
|
||||||
searchField.placeholderAttributedString = NSAttributedString(
|
searchField.placeholderAttributedString = NSAttributedString(
|
||||||
string: "Enter English text to translate…",
|
string: placeholder,
|
||||||
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
||||||
)
|
)
|
||||||
searchField.setAccessibilityLabel("English text to translate into Japanese")
|
searchField.setAccessibilityLabel(accessibilityLabel)
|
||||||
for view in launcherViews.dropFirst() {
|
for view in launcherViews.dropFirst() {
|
||||||
view.isHidden = true
|
view.isHidden = true
|
||||||
view.setAccessibilityHidden(true)
|
view.setAccessibilityHidden(true)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
public struct Application: Identifiable, Sendable {
|
public struct Application: Identifiable, Sendable {
|
||||||
public enum Command: String, CaseIterable, Sendable {
|
public enum Command: String, CaseIterable, Sendable {
|
||||||
case translate
|
case translate
|
||||||
|
case emoji
|
||||||
}
|
}
|
||||||
public static let commands = Command.allCases.map { Application(command: $0) }
|
public static let commands = Command.allCases.map { Application(command: $0) }
|
||||||
public let command: Command?
|
public let command: Command?
|
||||||
@@ -20,6 +21,7 @@ public struct Application: Identifiable, Sendable {
|
|||||||
public init(command: Command) {
|
public init(command: Command) {
|
||||||
let name = switch command {
|
let name = switch command {
|
||||||
case .translate: "Conjure Translate"
|
case .translate: "Conjure Translate"
|
||||||
|
case .emoji: "Conjure Emoji"
|
||||||
}
|
}
|
||||||
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
|
self.init(canonicalURL: URL(string: "conjure-command://\(command.rawValue)")!, name: name, command: command)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Emoji: Sendable {
|
||||||
|
public let value: String
|
||||||
|
public let name: String
|
||||||
|
public let category: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EmojiCatalog {
|
||||||
|
private struct Entry: Sendable {
|
||||||
|
let emoji: Emoji
|
||||||
|
let searchText: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let entries: [Entry] = {
|
||||||
|
// SwiftPM's generated accessor does not search Contents/Resources in a packaged app.
|
||||||
|
let packagedBundle = Bundle.main.resourceURL.flatMap {
|
||||||
|
Bundle(url: $0.appendingPathComponent("Conjure_ConjureKit.bundle", isDirectory: true))
|
||||||
|
}
|
||||||
|
let bundle = packagedBundle ?? Bundle.module
|
||||||
|
guard let url = bundle.url(forResource: "emoji", withExtension: "tsv"),
|
||||||
|
let contents = try? String(contentsOf: url, encoding: .utf8) else {
|
||||||
|
preconditionFailure("The bundled emoji catalog is missing or unreadable")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned github/gemoji v4.1.0; attribution and license accompany the TSV.
|
||||||
|
return contents.split(whereSeparator: \.isNewline).map { line in
|
||||||
|
let fields = line.split(separator: "\t", omittingEmptySubsequences: false)
|
||||||
|
precondition(fields.count == 5, "Invalid bundled emoji catalog row")
|
||||||
|
return Entry(
|
||||||
|
emoji: Emoji(value: String(fields[0]), name: String(fields[1]), category: String(fields[2])),
|
||||||
|
searchText: normalized([fields[0], fields[1], fields[3], fields[4]].joined(separator: " "))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
public static let all: [Emoji] = entries.map(\.emoji)
|
||||||
|
|
||||||
|
public static let categories: [String] = {
|
||||||
|
var seen = Set<String>()
|
||||||
|
return all.compactMap { seen.insert($0.category).inserted ? $0.category : nil }
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Matches every query word against names, GitHub aliases, tags, or literal emoji.
|
||||||
|
/// Category names use the exact values supplied by `categories`; results retain catalog order.
|
||||||
|
public static func search(_ query: String, category: String? = nil) -> [Emoji] {
|
||||||
|
let words = normalized(query).split(whereSeparator: \.isWhitespace)
|
||||||
|
if words.isEmpty && category == nil { return all }
|
||||||
|
return entries.compactMap { entry in
|
||||||
|
guard category == nil || entry.emoji.category == category,
|
||||||
|
words.allSatisfy({ entry.searchText.contains($0) }) else { return nil }
|
||||||
|
return entry.emoji
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalized(_ text: String) -> String {
|
||||||
|
text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
||||||
|
.replacingOccurrences(of: "_", with: " ")
|
||||||
|
.replacingOccurrences(of: ":", with: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
Emoji catalog derived from github/gemoji v4.1.0.
|
||||||
|
Source: https://raw.githubusercontent.com/github/gemoji/v4.1.0/db/emoji.json
|
||||||
|
License: https://raw.githubusercontent.com/github/gemoji/v4.1.0/LICENSE
|
||||||
|
|
||||||
|
TSV columns: emoji, description, category, space-separated aliases, space-separated tags.
|
||||||
|
|
||||||
|
Copyright (c) 2019 GitHub, Inc.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person
|
||||||
|
obtaining a copy of this software and associated documentation
|
||||||
|
files (the "Software"), to deal in the Software without
|
||||||
|
restriction, including without limitation the rights to use,
|
||||||
|
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the
|
||||||
|
Software is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||||
|
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||||
|
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
|||||||
- [ ] add an emoji picker inspired raycast's emoji picker
|
- [x] add an emoji picker inspired raycast's emoji picker
|
||||||
- [ ] add a notes app inspired by raycast notes
|
- [ ] add a notes app inspired by raycast notes
|
||||||
- [ ] improve on the conjure translation command
|
- [ ] improve on the conjure translation command
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Test func emojiCatalogSupportsOfflineSearchAndOrderedCategories() {
|
||||||
|
#expect(EmojiCatalog.search(" \n ").map(\.value) == EmojiCatalog.all.map(\.value))
|
||||||
|
#expect(EmojiCatalog.search("", category: "Flags").map(\.value)
|
||||||
|
== EmojiCatalog.all.filter { $0.category == "Flags" }.map(\.value))
|
||||||
|
|
||||||
|
// Names, shortcodes, aliases, tags, and literals share the same search path.
|
||||||
|
#expect(EmojiCatalog.search("woman tech").map(\.value) == ["👩💻"])
|
||||||
|
#expect(EmojiCatalog.search(":WOMAN_TECHNOLOGIST:").map(\.value) == ["👩💻"])
|
||||||
|
#expect(EmojiCatalog.search("coder woman", category: "People & Body").map(\.value) == ["👩💻"])
|
||||||
|
#expect(EmojiCatalog.search("coder woman", category: "Objects").isEmpty)
|
||||||
|
#expect(EmojiCatalog.search(":+1:").map(\.value) == ["👍"])
|
||||||
|
#expect(EmojiCatalog.search("CÁFÉ", category: "Food & Drink").contains { $0.value == "☕" })
|
||||||
|
#expect(EmojiCatalog.search("👩💻").map(\.value) == ["👩💻"])
|
||||||
|
#expect(EmojiCatalog.search("🇩🇰", category: "Flags").map(\.value) == ["🇩🇰"])
|
||||||
|
#expect(EmojiCatalog.search("no-such-emoji").isEmpty)
|
||||||
|
#expect(EmojiCatalog.search("", category: "no-such-category").isEmpty)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import AppKit
|
||||||
|
import ConjureKit
|
||||||
|
import Testing
|
||||||
|
@testable import Conjure
|
||||||
|
|
||||||
|
@Test @MainActor func emojiPickerNavigatesCopiesAndRemembersRecentSelections() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "ConjureEmoji.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
let pasteboard = NSPasteboard.withUniqueName()
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
pasteboard.releaseGlobally()
|
||||||
|
}
|
||||||
|
var query = "face"
|
||||||
|
var closed = false
|
||||||
|
let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
|
||||||
|
picker.input = { query }
|
||||||
|
picker.closed = { closed = true }
|
||||||
|
_ = picker.view
|
||||||
|
let matches = EmojiCatalog.search(query)
|
||||||
|
#expect(picker.handle(#selector(NSResponder.moveRight(_:))))
|
||||||
|
#expect(picker.handle(#selector(NSResponder.insertNewline(_:))))
|
||||||
|
#expect(pasteboard.string(forType: .string) == matches[1].value)
|
||||||
|
#expect(closed)
|
||||||
|
#expect(defaults.stringArray(forKey: "recentEmoji") == [matches[1].value])
|
||||||
|
|
||||||
|
closed = false
|
||||||
|
query = "no-such-emoji"
|
||||||
|
picker.inputChanged()
|
||||||
|
picker.copySelection()
|
||||||
|
#expect(!closed)
|
||||||
|
#expect(pasteboard.string(forType: .string) == matches[1].value)
|
||||||
|
query = "👩💻"
|
||||||
|
picker.inputChanged()
|
||||||
|
picker.copySelection()
|
||||||
|
#expect(pasteboard.string(forType: .string) == "👩💻")
|
||||||
|
|
||||||
|
let reopened = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
|
||||||
|
_ = reopened.view
|
||||||
|
reopened.copySelection()
|
||||||
|
#expect(pasteboard.string(forType: .string) == "👩💻")
|
||||||
|
#expect(defaults.stringArray(forKey: "recentEmoji") == ["👩💻", matches[1].value])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func emojiCommandRestoresLauncherSearchOnEscape() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
let suite = "ConjureEmojiCommand.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let history = LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) })
|
||||||
|
let launcher = LauncherWindowController(history: history)
|
||||||
|
let content = try #require(launcher.window?.contentView)
|
||||||
|
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
|
||||||
|
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
|
||||||
|
search.stringValue = "emoji"
|
||||||
|
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||||
|
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
|
||||||
|
let grid = try #require(descendants(content).compactMap { $0 as? NSCollectionView }.first)
|
||||||
|
search.stringValue = ":woman_technologist:"
|
||||||
|
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
|
||||||
|
#expect(grid.numberOfItems(inSection: 0) == 1)
|
||||||
|
#expect((grid.dataSource?.collectionView(grid, itemForRepresentedObjectAt: IndexPath(item: 0, section: 0)).view as? NSButton)?.title == "👩💻")
|
||||||
|
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.cancelOperation(_:)))
|
||||||
|
#expect(!descendants(content).contains { $0 === grid })
|
||||||
|
#expect(search.stringValue == "emoji")
|
||||||
|
let table = try #require(descendants(content).compactMap { $0 as? NSTableView }.first)
|
||||||
|
#expect(table.numberOfRows == 1)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user