feat: add searchable emoji picker

This commit is contained in:
Valdemar Lorenzen
2026-10-15 13:59:10 +02:00
parent 6574c97276
commit 1de7983345
12 changed files with 2322 additions and 43 deletions
+68 -40
View File
@@ -5,7 +5,7 @@ import ConjureKit
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField()
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 statusLabel = 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 launcherFrameBeforeReview: NSRect?
private var translation: TranslationController?
private var emojiPicker: EmojiPickerController?
private var isCommandOpen: Bool { translation != nil || emojiPicker != nil }
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
self.history = history
@@ -61,13 +63,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
guard let self else { return }
if let review = self.uninstallReview { review.moveSelection(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) }
}
window.dismiss = { [weak self] in self?.dismiss() }
window.canUseNumberShortcuts = { [weak self] in
guard let self else { return false }
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.launchNumber = { [weak self] index in
@@ -98,7 +101,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func present() {
(window as? LauncherPanel)?.beginNumberHintSession()
if launcherFrameBeforeReview != nil || translation != nil {
if launcherFrameBeforeReview != nil || isCommandOpen {
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
@@ -134,8 +137,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); return }
if let translation {
translation.goBack()
if isCommandOpen {
goBackCommand()
if restoringFocus { return }
}
guard launcherFrameBeforeReview == nil else { return }
@@ -230,18 +233,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
searchSymbol.contentTintColor = SunsetPalette.accent
translationBack.image = IconStyle.symbol("chevron.left", for: searchFont)
translationBack.imageScaling = .scaleProportionallyDown
translationBack.isBordered = false
translationBack.contentTintColor = SunsetPalette.secondaryText
translationBack.setAccessibilityLabel("Back to applications")
translationBack.toolTip = "Back to applications (Escape)"
translationBack.isHidden = true
commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
commandBack.imageScaling = .scaleProportionallyDown
commandBack.isBordered = false
commandBack.contentTintColor = SunsetPalette.secondaryText
commandBack.setAccessibilityLabel("Back to applications")
commandBack.toolTip = "Back to applications (Escape)"
commandBack.isHidden = true
NSLayoutConstraint.activate([
translationBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
translationBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
commandBack.widthAnchor.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.alignment = .centerY
header.spacing = 12
@@ -431,7 +434,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
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)
? results[tableView.selectedRow].id : nil
let query = searchField.stringValue
@@ -497,10 +500,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
func controlTextDidChange(_ notification: Notification) {
if let translation { translation.inputChanged(); return }
if let emojiPicker { emojiPicker.inputChanged(); return }
updateResults()
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if let emojiPicker { return emojiPicker.handle(commandSelector) }
if let translation {
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); 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 func moveSelection(by offset: Int) {
guard translation == nil else { return }
guard !isCommandOpen else { return }
guard resultCount > 0 else { return }
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
@@ -532,7 +537,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
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
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
? "Open Command" : "Open Application") : "Copy Answer"
@@ -584,9 +589,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
cell.locationLabel.isHidden = true
cell.kindLabel.attributedStringValue = history.isFavorite(entry)
? 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.toolTip = "Translate English to Japanese"
cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
} else {
let application = results[row]
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) {
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.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
}
@@ -639,7 +644,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
@objc private func showActions() {
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()
let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) }
@@ -727,7 +732,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
@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 {
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
let pasteboard = NSPasteboard.general
@@ -753,11 +758,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
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 {
switch command {
case .translate: showTranslation()
}
showCommand(command)
history.recordLaunch(of: application)
return
}
@@ -781,19 +784,42 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func showTranslation() {
guard translation == nil, let content = window?.contentView else { return }
let controller = TranslationController()
translation = controller
controller.input = { [weak self] in self?.searchField.stringValue ?? "" }
@objc private func goBackCommand() {
translation?.goBack()
emojiPicker?.goBack()
}
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 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 }
controller.view.removeFromSuperview()
self.translation = nil
self.translationBack.isHidden = true
self.translationBack.target = nil
self.emojiPicker = nil
self.commandBack.isHidden = true
self.commandBack.target = nil
self.searchSymbol.isHidden = false
for view in self.launcherViews.dropFirst() {
view.isHidden = false
@@ -807,19 +833,21 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
self.updateResults()
self.window?.makeFirstResponder(self.searchField)
}
translation?.closed = close
emojiPicker?.closed = close
actions.close()
(window as? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
searchSymbol.isHidden = true
translationBack.target = controller
translationBack.action = #selector(TranslationController.goBack)
translationBack.isHidden = false
commandBack.target = self
commandBack.action = #selector(goBackCommand)
commandBack.isHidden = false
searchField.stringValue = ""
searchField.placeholderAttributedString = NSAttributedString(
string: "Enter English text to translate…",
string: placeholder,
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
)
searchField.setAccessibilityLabel("English text to translate into Japanese")
searchField.setAccessibilityLabel(accessibilityLabel)
for view in launcherViews.dropFirst() {
view.isHidden = true
view.setAccessibilityHidden(true)