feat: add searchable emoji picker
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user