| | |
| | | static let tagLauncherQuickSearchRequested = Notification.Name("TagLauncherQuickSearchRequested") |
| | | static let tagLauncherQuickSearchDismissRequested = Notification.Name("TagLauncherQuickSearchDismissRequested") |
| | | static let tagLauncherQuickSearchVisibilityChanged = Notification.Name("TagLauncherQuickSearchVisibilityChanged") |
| | | static let tagLauncherQuickSearchCompositionChanged = Notification.Name("TagLauncherQuickSearchCompositionChanged") |
| | | static let tagLauncherHotkeyRegistrationChanged = Notification.Name("TagLauncherHotkeyRegistrationChanged") |
| | | static let tagLauncherProEntitlementChanged = Notification.Name("TagLauncherProEntitlementChanged") |
| | | } |
| | | |
| | | enum QuickSearchOpenSource { |
| | |
| | | } |
| | | return "Key \(keyCode)" |
| | | } |
| | | } |
| | | |
| | | static var printableKeyCodes: Set<Int> { |
| | | Set(keyCodeToPrintableScalar.keys) |
| | | } |
| | | |
| | | private static let keyCodeToPrintableScalar: [Int: Character] = [ |
| | |
| | | |
| | | enum QuickSearchEngine { |
| | | static func makeDocuments(apps: [AppInfo], store: TagDatabase.Store) -> [QuickSearchDocument] { |
| | | apps.map { app in |
| | | let localizedNames = uniqueOrdered(app.localizedNames) |
| | | let internalBundleNames = internalBundleNames(for: app) |
| | | let note = store.appNotes[app.path.path] ?? app.note ?? "" |
| | | let bundleIdentifier = app.bundleIdentifier ?? "" |
| | | let searchableFields = makeSearchableFields( |
| | | appName: app.name, |
| | | localizedNames: localizedNames, |
| | | internalBundleNames: internalBundleNames, |
| | | tagNames: app.tags, |
| | | note: note, |
| | | bundleIdentifier: bundleIdentifier |
| | | ) |
| | | return QuickSearchDocument( |
| | | app: app, |
| | | localizedNames: localizedNames, |
| | | internalBundleNames: internalBundleNames, |
| | | tagNames: app.tags, |
| | | note: note, |
| | | bundleIdentifier: bundleIdentifier, |
| | | lastOpenedAt: store.appLastOpenedAt[app.path.path], |
| | | openCount: store.appOpenCounts[app.path.path] ?? 0, |
| | | searchableFields: searchableFields |
| | | ) |
| | | } |
| | | apps |
| | | .filter { !AppIndexer.isInternalHelperAppPath($0.path) } |
| | | .map { app in |
| | | let localizedNames = uniqueOrdered(AppDisplayNameResolver.searchAliases(for: app)) |
| | | let internalBundleNames = internalBundleNames(for: app) |
| | | let note = store.appNotes[app.path.path] ?? app.note ?? "" |
| | | let bundleIdentifier = app.bundleIdentifier ?? "" |
| | | let searchableFields = makeSearchableFields( |
| | | appName: app.name, |
| | | localizedNames: localizedNames, |
| | | internalBundleNames: internalBundleNames, |
| | | tagNames: app.tags, |
| | | note: note, |
| | | bundleIdentifier: bundleIdentifier |
| | | ) |
| | | return QuickSearchDocument( |
| | | app: app, |
| | | localizedNames: localizedNames, |
| | | internalBundleNames: internalBundleNames, |
| | | tagNames: app.tags, |
| | | note: note, |
| | | bundleIdentifier: bundleIdentifier, |
| | | lastOpenedAt: store.appLastOpenedAt[app.path.path], |
| | | openCount: store.appOpenCounts[app.path.path] ?? 0, |
| | | searchableFields: searchableFields |
| | | ) |
| | | } |
| | | } |
| | | |
| | | static func search(_ query: String, documents: [QuickSearchDocument], limit: Int = 50) -> [QuickSearchResult] { |
| | |
| | | return normalizeField(String(parts)) |
| | | } |
| | | |
| | | private static func localizedNames(for app: AppInfo) -> [String] { |
| | | guard let bundle = Bundle(url: app.path) else { return [] } |
| | | let values = [ |
| | | bundle.localizedInfoDictionary?["CFBundleDisplayName"] as? String, |
| | | bundle.infoDictionary?["CFBundleDisplayName"] as? String, |
| | | FileManager.default.displayName(atPath: app.path.path).replacingOccurrences(of: ".app", with: "") |
| | | ] |
| | | return uniqueOrdered(values.compactMap { $0 }.compactMap { value in |
| | | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | return trimmed.isEmpty || trimmed == app.name ? nil : trimmed |
| | | }) |
| | | } |
| | | |
| | | private static func internalBundleNames(for app: AppInfo) -> [String] { |
| | | guard let bundle = Bundle(url: app.path) else { return [] } |
| | | let values = [ |
| | |
| | | self.parentWindow = parentWindow |
| | | } |
| | | |
| | | let shouldOrderForInput = shouldOrderPanelForInput(panel) |
| | | if panel.frame != frame { |
| | | panel.setFrame(frame, display: true) |
| | | } |
| | | panel.makeKeyAndOrderFront(nil) |
| | | panel.orderFrontRegardless() |
| | | if shouldOrderForInput { |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | panel.makeKeyAndOrderFront(nil) |
| | | panel.orderFrontRegardless() |
| | | } |
| | | } |
| | | |
| | | func closePanel() { |
| | |
| | | |
| | | let panel = QuickSearchPanel( |
| | | contentRect: .zero, |
| | | styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], |
| | | styleMask: [.borderless, .fullSizeContentView], |
| | | backing: .buffered, |
| | | defer: false |
| | | ) |
| | |
| | | self.panel = panel |
| | | self.hostingView = hostingView |
| | | return panel |
| | | } |
| | | |
| | | private func shouldOrderPanelForInput(_ panel: QuickSearchPanel) -> Bool { |
| | | !panel.isVisible || !panel.isKeyWindow || NSApp.keyWindow !== panel |
| | | } |
| | | |
| | | private func scheduleDeferredApply( |
| | |
| | | override var canBecomeKey: Bool { true } |
| | | override var canBecomeMain: Bool { false } |
| | | |
| | | override func sendEvent(_ event: NSEvent) { |
| | | if event.type == .leftMouseDown, |
| | | let row = quickSearchResultRow(at: event.locationInWindow) { |
| | | row.launchFromMouseClick() |
| | | return |
| | | } |
| | | super.sendEvent(event) |
| | | } |
| | | |
| | | override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { |
| | | frameRect |
| | | } |
| | | |
| | | private func quickSearchResultRow(at windowPoint: NSPoint) -> QuickSearchResultRowView? { |
| | | guard let contentView else { return nil } |
| | | if let row = contentView.quickSearchResultRowFromHitTest(atWindowPoint: windowPoint) { |
| | | return row |
| | | } |
| | | return contentView.quickSearchResultRowDescendant(containingWindowPoint: windowPoint) |
| | | } |
| | | } |
| | | |
| | | private extension NSView { |
| | | func quickSearchResultRowFromHitTest(atWindowPoint windowPoint: NSPoint) -> QuickSearchResultRowView? { |
| | | let localPoint = convert(windowPoint, from: nil) |
| | | var hitView = hitTest(localPoint) |
| | | while let candidate = hitView { |
| | | if let row = candidate as? QuickSearchResultRowView { |
| | | return row |
| | | } |
| | | hitView = candidate.superview |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | func quickSearchResultRowDescendant(containingWindowPoint windowPoint: NSPoint) -> QuickSearchResultRowView? { |
| | | for subview in subviews.reversed() { |
| | | if let row = subview as? QuickSearchResultRowView, |
| | | row.containsWindowPointForLaunch(windowPoint) { |
| | | return row |
| | | } |
| | | if let row = subview.quickSearchResultRowDescendant(containingWindowPoint: windowPoint) { |
| | | return row |
| | | } |
| | | } |
| | | return nil |
| | | } |
| | | } |
| | | |
| | |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | guard !isHidden, alphaValue > 0, bounds.contains(point) else { return nil } |
| | | return self |
| | | } |
| | | |
| | | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | super.mouseEntered(with: event) |
| | | if let result { |
| | |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | if let result { |
| | | DispatchQueue.main.async { [onLaunch] in |
| | | onLaunch(result) |
| | | } |
| | | } else { |
| | | if !launchFromMouseClick() { |
| | | super.mouseDown(with: event) |
| | | } |
| | | } |
| | | |
| | | @discardableResult |
| | | func launchFromMouseClick() -> Bool { |
| | | guard let result else { return false } |
| | | DispatchQueue.main.async { [onLaunch, result] in |
| | | onLaunch(result) |
| | | } |
| | | return true |
| | | } |
| | | |
| | | func containsWindowPointForLaunch(_ windowPoint: NSPoint) -> Bool { |
| | | guard window != nil else { return false } |
| | | var view: NSView? = self |
| | | while let candidate = view { |
| | | if candidate.isHidden || candidate.alphaValue <= 0 { |
| | | return false |
| | | } |
| | | view = candidate.superview |
| | | } |
| | | let localPoint = convert(windowPoint, from: nil) |
| | | return bounds.contains(localPoint) && visibleRect.contains(localPoint) |
| | | } |
| | | |
| | | private func setup() { |
| | |
| | | field.placeholderString = placeholder |
| | | field.delegate = context.coordinator |
| | | field.onCommand = onCommand |
| | | field.target = context.coordinator |
| | | field.action = #selector(Coordinator.submitAction(_:)) |
| | | context.coordinator.onCommand = onCommand |
| | | field.setAccessibilityLabel(tr("quickSearch.inputAccessibility")) |
| | | context.coordinator.field = field |
| | |
| | | } |
| | | |
| | | func updateNSView(_ field: QuickSearchNativeTextField, context: Context) { |
| | | if field.stringValue != text { |
| | | if !field.isComposingText, field.stringValue != text { |
| | | field.stringValue = text |
| | | } |
| | | field.publishCompositionStateIfNeeded() |
| | | field.placeholderString = placeholder |
| | | field.onCommand = onCommand |
| | | field.target = context.coordinator |
| | | field.action = #selector(Coordinator.submitAction(_:)) |
| | | context.coordinator.onCommand = onCommand |
| | | if context.coordinator.lastFocusToken != focusToken { |
| | | context.coordinator.lastFocusToken = focusToken |
| | |
| | | guard let field, |
| | | let window = field.window, |
| | | window.isVisible, |
| | | window.firstResponder !== field |
| | | field.currentEditor() == nil |
| | | else { return } |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | if !window.isKeyWindow || NSApp.keyWindow !== window { |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | } |
| | | window.makeFirstResponder(field) |
| | | } |
| | | } |
| | |
| | | var text: Binding<String> |
| | | var lastFocusToken = 0 |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | weak var field: NSTextField? |
| | | weak var field: QuickSearchNativeTextField? |
| | | |
| | | init(text: Binding<String>) { |
| | | self.text = text |
| | | } |
| | | |
| | | func controlTextDidChange(_ obj: Notification) { |
| | | guard let field = obj.object as? NSTextField else { return } |
| | | guard let field = obj.object as? QuickSearchNativeTextField else { return } |
| | | field.publishCompositionStateIfNeeded() |
| | | if !field.isComposingText { |
| | | text.wrappedValue = field.stringValue |
| | | } |
| | | } |
| | | |
| | | func controlTextDidEndEditing(_ obj: Notification) { |
| | | guard let field = obj.object as? QuickSearchNativeTextField else { return } |
| | | field.publishCompositionState(active: false, force: true) |
| | | text.wrappedValue = field.stringValue |
| | | } |
| | | |
| | | @objc func submitAction(_ sender: Any?) { |
| | | guard field?.isComposingText != true else { return } |
| | | onCommand?(.submit) |
| | | } |
| | | |
| | | func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { |
| | | if textView.hasMarkedText() { |
| | | (control as? QuickSearchNativeTextField)?.publishCompositionState(active: true) |
| | | return false |
| | | } |
| | | switch commandSelector { |
| | | case #selector(NSResponder.moveUp(_:)): |
| | | onCommand?(.moveUp) |
| | |
| | | |
| | | private final class QuickSearchNativeTextField: NSTextField { |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | private var lastPublishedCompositionActive = false |
| | | |
| | | override var acceptsFirstResponder: Bool { true } |
| | | |
| | | var isComposingText: Bool { |
| | | (currentEditor() as? NSTextView)?.hasMarkedText() == true |
| | | } |
| | | |
| | | func publishCompositionStateIfNeeded() { |
| | | publishCompositionState(active: isComposingText) |
| | | } |
| | | |
| | | func publishCompositionState(active: Bool, force: Bool = false) { |
| | | guard force || active != lastPublishedCompositionActive else { return } |
| | | lastPublishedCompositionActive = active |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchCompositionChanged, |
| | | object: self, |
| | | userInfo: ["active": active] |
| | | ) |
| | | } |
| | | |
| | | override func keyDown(with event: NSEvent) { |
| | | guard !isComposingText else { |
| | | super.keyDown(with: event) |
| | | return |
| | | } |
| | | switch Int(event.keyCode) { |
| | | case kVK_UpArrow: |
| | | onCommand?(.moveUp) |