| | |
| | | static let globalVisible = "globalVisible" |
| | | } |
| | | |
| | | enum QuickSearchDismissSource { |
| | | static let keyboard = "keyboard" |
| | | static let mouse = "mouse" |
| | | static let backdrop = "backdrop" |
| | | static let programmatic = "programmatic" |
| | | } |
| | | |
| | | // MARK: - Hotkeys |
| | | |
| | | struct LauncherHotkey: Equatable { |
| | |
| | | |
| | | enum QuickSearchPanelMetrics { |
| | | static let width: CGFloat = 760 |
| | | static let shadowOutset: CGFloat = 56 |
| | | static let rowHeight: CGFloat = 74 |
| | | static let rowSpacing: CGFloat = 2 |
| | | static let resultListVerticalInset: CGFloat = 10 |
| | |
| | | } |
| | | |
| | | func updateNSView(_ view: QuickSearchPanelAnchorView, context: Context) { |
| | | let size = NSSize(width: QuickSearchPanelMetrics.width, height: panelHeight) |
| | | let contentSize = NSSize(width: QuickSearchPanelMetrics.width, height: panelHeight) |
| | | let windowSize = NSSize( |
| | | width: contentSize.width + QuickSearchPanelMetrics.shadowOutset * 2, |
| | | height: contentSize.height + QuickSearchPanelMetrics.shadowOutset * 2 |
| | | ) |
| | | let rootView = QuickSearchPanelWindowContent( |
| | | size: size, |
| | | windowSize: windowSize, |
| | | contentSize: contentSize, |
| | | content: AnyView( |
| | | QuickSearchOverlayView( |
| | | query: $query, |
| | |
| | | context.coordinator.apply( |
| | | QuickSearchPanelConfiguration( |
| | | panelTopY: panelTopY, |
| | | size: size, |
| | | windowSize: windowSize, |
| | | contentSize: contentSize, |
| | | rootView: AnyView(rootView) |
| | | ), |
| | | anchorView: view |
| | |
| | | let frame = windowFrame( |
| | | parentWindow: parentWindow, |
| | | panelTopY: configuration.panelTopY, |
| | | size: configuration.size |
| | | contentSize: configuration.contentSize, |
| | | windowSize: configuration.windowSize |
| | | ) |
| | | |
| | | hostingView?.rootView = configuration.rootView |
| | | hostingView?.frame = NSRect(origin: .zero, size: configuration.size) |
| | | panel.contentView?.frame = NSRect(origin: .zero, size: configuration.size) |
| | | hostingView?.frame = NSRect(origin: .zero, size: configuration.windowSize) |
| | | panel.contentView?.frame = NSRect(origin: .zero, size: configuration.windowSize) |
| | | panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow) |
| | | panel.level = parentWindow.level |
| | | |
| | |
| | | private func windowFrame( |
| | | parentWindow: NSWindow, |
| | | panelTopY: CGFloat, |
| | | size: NSSize |
| | | contentSize: NSSize, |
| | | windowSize: NSSize |
| | | ) -> NSRect { |
| | | let parentFrame = parentWindow.frame |
| | | return NSRect( |
| | | x: parentFrame.minX + (parentFrame.width - size.width) / 2, |
| | | y: parentFrame.maxY - panelTopY - size.height, |
| | | width: size.width, |
| | | height: size.height |
| | | x: parentFrame.minX + (parentFrame.width - contentSize.width) / 2 - QuickSearchPanelMetrics.shadowOutset, |
| | | y: parentFrame.maxY - panelTopY - contentSize.height - QuickSearchPanelMetrics.shadowOutset, |
| | | width: windowSize.width, |
| | | height: windowSize.height |
| | | ) |
| | | } |
| | | |
| | |
| | | |
| | | struct QuickSearchPanelConfiguration { |
| | | let panelTopY: CGFloat |
| | | let size: NSSize |
| | | let windowSize: NSSize |
| | | let contentSize: NSSize |
| | | let rootView: AnyView |
| | | } |
| | | |
| | | private struct QuickSearchPanelWindowContent: View { |
| | | let size: NSSize |
| | | let windowSize: NSSize |
| | | let contentSize: NSSize |
| | | let content: AnyView |
| | | |
| | | var body: some View { |
| | | content |
| | | .frame(width: size.width, height: size.height, alignment: .top) |
| | | VStack(spacing: 0) { |
| | | content |
| | | .frame(width: contentSize.width, height: contentSize.height, alignment: .top) |
| | | Spacer(minLength: 0) |
| | | } |
| | | .padding(.top, QuickSearchPanelMetrics.shadowOutset) |
| | | .padding(.horizontal, QuickSearchPanelMetrics.shadowOutset) |
| | | .padding(.bottom, QuickSearchPanelMetrics.shadowOutset) |
| | | .frame(width: windowSize.width, height: windowSize.height, alignment: .top) |
| | | .background(Color.clear) |
| | | } |
| | | } |
| | | |
| | |
| | | colorScheme == .dark |
| | | ? Color(red: 0.105, green: 0.110, blue: 0.125).opacity(0.97) |
| | | : Color.white.opacity(0.97) |
| | | } |
| | | |
| | | private var panelShape: RoundedRectangle { |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | } |
| | | |
| | | var body: some View { |
| | |
| | | } |
| | | .frame(width: panelWidth) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | .fill(panelBackgroundColor) |
| | | .shadow(color: .black.opacity(0.18), radius: 36, y: 18) |
| | | panelShape.fill(panelBackgroundColor) |
| | | ) |
| | | .clipShape(panelShape) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | .stroke(Color.primary.opacity(0.10), lineWidth: 1) |
| | | panelShape.stroke(Color.primary.opacity(0.10), lineWidth: 1) |
| | | ) |
| | | .compositingGroup() |
| | | .shadow(color: .black.opacity(colorScheme == .dark ? 0.46 : 0.26), radius: 30, x: 0, y: 18) |
| | | .shadow(color: .black.opacity(colorScheme == .dark ? 0.24 : 0.12), radius: 8, x: 0, y: 3) |
| | | .accessibilityElement(children: .contain) |
| | | .accessibilityLabel(tr("quickSearch.title")) |
| | | } |
| | |
| | | } |
| | | |
| | | final class QuickSearchResultRowView: NSView { |
| | | private enum TagBadgeLayout { |
| | | static let columnWidth: CGFloat = 196 |
| | | static let minWidth: CGFloat = 80 |
| | | static let maxWidth: CGFloat = 196 |
| | | static let height: CGFloat = 32 |
| | | static let horizontalInset: CGFloat = 16 |
| | | static let labelSafetyWidth: CGFloat = 8 |
| | | static let trailingInset: CGFloat = 18 |
| | | static let textGap: CGFloat = 18 |
| | | static let minimumMainTextWidth: CGFloat = 260 |
| | | } |
| | | |
| | | private let iconView = NSImageView() |
| | | private let titleLabel = NSTextField(labelWithString: "") |
| | | private let detailLabel = NSTextField(labelWithString: "") |
| | |
| | | height: iconSize |
| | | ) |
| | | |
| | | let tagMaxWidth: CGFloat = 128 |
| | | let tagHeight: CGFloat = 32 |
| | | let trailingInset: CGFloat = 18 |
| | | let textX = iconX + iconSize + 16 |
| | | let trailingInset = TagBadgeLayout.trailingInset |
| | | let rightLimit = bounds.width - trailingInset |
| | | var tagFrame = NSRect.zero |
| | | if !tagBackgroundView.isHidden { |
| | | let labelWidth = min(tagMaxWidth - 24, max(22, tagLabel.intrinsicContentSize.width)) |
| | | let tagWidth = min(tagMaxWidth, labelWidth + 24) |
| | | let hasTag = !tagLabel.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | let availableBadgeColumnWidth = rightLimit - textX - TagBadgeLayout.minimumMainTextWidth - TagBadgeLayout.textGap |
| | | let tagColumnWidth = min(TagBadgeLayout.columnWidth, max(0, availableBadgeColumnWidth)) |
| | | let canShowTag = hasTag && tagColumnWidth >= TagBadgeLayout.minWidth |
| | | tagBackgroundView.isHidden = !canShowTag |
| | | |
| | | if canShowTag { |
| | | let measuredLabelWidth = measuredTagLabelWidth() |
| | | let measuredBadgeWidth = measuredLabelWidth |
| | | + TagBadgeLayout.horizontalInset * 2 |
| | | + TagBadgeLayout.labelSafetyWidth |
| | | let tagWidth = min( |
| | | min(TagBadgeLayout.maxWidth, tagColumnWidth), |
| | | max(TagBadgeLayout.minWidth, measuredBadgeWidth) |
| | | ) |
| | | tagFrame = NSRect( |
| | | x: bounds.width - trailingInset - tagWidth, |
| | | y: (bounds.height - tagHeight) / 2, |
| | | x: rightLimit - tagWidth, |
| | | y: (bounds.height - TagBadgeLayout.height) / 2, |
| | | width: tagWidth, |
| | | height: tagHeight |
| | | height: TagBadgeLayout.height |
| | | ) |
| | | tagBackgroundView.frame = tagFrame |
| | | tagLabel.frame = NSRect(x: 12, y: 6, width: tagWidth - 24, height: 20) |
| | | tagLabel.frame = NSRect( |
| | | x: TagBadgeLayout.horizontalInset, |
| | | y: 6, |
| | | width: tagWidth - TagBadgeLayout.horizontalInset * 2, |
| | | height: 20 |
| | | ) |
| | | } else { |
| | | tagBackgroundView.frame = .zero |
| | | tagLabel.frame = .zero |
| | | } |
| | | |
| | | let textX = iconX + iconSize + 16 |
| | | let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - 16 |
| | | let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - TagBadgeLayout.textGap |
| | | let textWidth = max(40, textRight - textX) |
| | | if detailLabel.isHidden { |
| | | titleLabel.frame = NSRect(x: textX, y: (bounds.height - 26) / 2, width: textWidth, height: 26) |
| | |
| | | titleLabel.frame = NSRect(x: textX, y: 14, width: textWidth, height: 25) |
| | | detailLabel.frame = NSRect(x: textX, y: 42, width: textWidth, height: 21) |
| | | } |
| | | } |
| | | |
| | | private func measuredTagLabelWidth() -> CGFloat { |
| | | let font = tagLabel.font ?? NSFont.systemFont(ofSize: 14, weight: .semibold) |
| | | return ceil((tagLabel.stringValue as NSString).size(withAttributes: [.font: font]).width) |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | if let result { |
| | | onLaunch(result) |
| | | DispatchQueue.main.async { [onLaunch] in |
| | | onLaunch(result) |
| | | } |
| | | } else { |
| | | super.mouseDown(with: event) |
| | | } |
| | |
| | | tagLabel.lineBreakMode = .byTruncatingTail |
| | | tagLabel.maximumNumberOfLines = 1 |
| | | tagLabel.backgroundColor = .clear |
| | | tagLabel.allowsDefaultTighteningForTruncation = true |
| | | tagLabel.cell?.usesSingleLineMode = true |
| | | tagLabel.cell?.lineBreakMode = .byTruncatingTail |
| | | } |
| | | |
| | | private func updateColors() { |
| | |
| | | } |
| | | |
| | | private func requestFocus(_ field: QuickSearchNativeTextField) { |
| | | DispatchQueue.main.async { [weak field] in |
| | | guard let field, |
| | | field.window?.firstResponder !== field |
| | | else { return } |
| | | field.window?.makeFirstResponder(field) |
| | | for delay in [0.0, 0.03, 0.08, 0.16] { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak field] in |
| | | guard let field, |
| | | let window = field.window, |
| | | window.isVisible, |
| | | window.firstResponder !== field |
| | | else { return } |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | window.makeFirstResponder(field) |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | private final class QuickSearchNativeTextField: NSTextField { |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | |
| | | override var acceptsFirstResponder: Bool { true } |
| | | |
| | | override func keyDown(with event: NSEvent) { |
| | | switch Int(event.keyCode) { |
| | | case kVK_UpArrow: |