Ariver
2026-06-03 a7f2f5f48368c92bf135a64b8c31fe2d389a672f
Apptag/QuickSearch.swift
@@ -729,6 +729,271 @@
    case dismiss
}
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
    static let headerHeight: CGFloat = 84
    static let dividerHeight: CGFloat = 1
    static let messageRowHeight: CGFloat = 86
    static func contentHeight(hasResultList: Bool, visibleRows: Int) -> CGFloat {
        if hasResultList {
            return headerHeight
                + dividerHeight
                + CGFloat(max(1, visibleRows)) * (rowHeight + rowSpacing)
                + resultListVerticalInset * 2
        }
        return headerHeight + dividerHeight + messageRowHeight
    }
}
struct QuickSearchPanelPresentationView: NSViewRepresentable {
    @Binding var query: String
    let results: [QuickSearchResult]
    let selectedID: URL?
    let focusToken: Int
    let selectionScrollToken: Int
    let isLoading: Bool
    let maxVisibleRows: Int
    let panelTopY: CGFloat
    let panelHeight: CGFloat
    let errorMessage: String?
    let onCommand: (QuickSearchCommand) -> Void
    let onHover: (QuickSearchResult) -> Void
    let onLaunch: (QuickSearchResult) -> Void
    func makeNSView(context: Context) -> QuickSearchPanelAnchorView {
        QuickSearchPanelAnchorView()
    }
    func updateNSView(_ view: QuickSearchPanelAnchorView, context: Context) {
        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(
            windowSize: windowSize,
            contentSize: contentSize,
            content: AnyView(
                QuickSearchOverlayView(
                    query: $query,
                    results: results,
                    selectedID: selectedID,
                    focusToken: focusToken,
                    selectionScrollToken: selectionScrollToken,
                    isLoading: isLoading,
                    maxVisibleRows: maxVisibleRows,
                    errorMessage: errorMessage,
                    onCommand: onCommand,
                    onHover: onHover,
                    onLaunch: onLaunch
                )
            )
        )
        context.coordinator.apply(
            QuickSearchPanelConfiguration(
                panelTopY: panelTopY,
                windowSize: windowSize,
                contentSize: contentSize,
                rootView: AnyView(rootView)
            ),
            anchorView: view
        )
    }
    static func dismantleNSView(_ nsView: QuickSearchPanelAnchorView, coordinator: Coordinator) {
        coordinator.closePanel()
    }
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    final class Coordinator {
        private var panel: QuickSearchPanel?
        private var hostingView: NSHostingView<AnyView>?
        private weak var parentWindow: NSWindow?
        private var deferredApplyPending = false
        func apply(_ configuration: QuickSearchPanelConfiguration, anchorView: QuickSearchPanelAnchorView) {
            guard let parentWindow = anchorView.window else {
                scheduleDeferredApply(configuration, anchorView: anchorView)
                return
            }
            deferredApplyPending = false
            let panel = ensurePanel(parentWindow: parentWindow)
            let frame = windowFrame(
                parentWindow: parentWindow,
                panelTopY: configuration.panelTopY,
                contentSize: configuration.contentSize,
                windowSize: configuration.windowSize
            )
            hostingView?.rootView = configuration.rootView
            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
            if panel.parent !== parentWindow {
                if let previousParent = panel.parent {
                    previousParent.removeChildWindow(panel)
                }
                parentWindow.addChildWindow(panel, ordered: .above)
                self.parentWindow = parentWindow
            }
            if panel.frame != frame {
                panel.setFrame(frame, display: true)
            }
            panel.makeKeyAndOrderFront(nil)
            panel.orderFrontRegardless()
        }
        func closePanel() {
            if let panel {
                if let parent = panel.parent {
                    parent.removeChildWindow(panel)
                }
                panel.orderOut(nil)
            }
            panel = nil
            hostingView = nil
            parentWindow = nil
            deferredApplyPending = false
        }
        private func ensurePanel(parentWindow: NSWindow) -> QuickSearchPanel {
            if let panel {
                return panel
            }
            let panel = QuickSearchPanel(
                contentRect: .zero,
                styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel],
                backing: .buffered,
                defer: false
            )
            panel.isFloatingPanel = true
            panel.hidesOnDeactivate = false
            panel.isOpaque = false
            panel.backgroundColor = .clear
            panel.hasShadow = false
            panel.titlebarAppearsTransparent = true
            panel.titleVisibility = .hidden
            panel.isReleasedWhenClosed = false
            panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow)
            let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
            hostingView.wantsLayer = true
            hostingView.layer?.backgroundColor = NSColor.clear.cgColor
            panel.contentView = hostingView
            self.panel = panel
            self.hostingView = hostingView
            return panel
        }
        private func scheduleDeferredApply(
            _ configuration: QuickSearchPanelConfiguration,
            anchorView: QuickSearchPanelAnchorView
        ) {
            guard !deferredApplyPending else { return }
            deferredApplyPending = true
            DispatchQueue.main.async { [weak self, weak anchorView] in
                guard let self, let anchorView else { return }
                self.deferredApplyPending = false
                self.apply(configuration, anchorView: anchorView)
            }
        }
        private func windowFrame(
            parentWindow: NSWindow,
            panelTopY: CGFloat,
            contentSize: NSSize,
            windowSize: NSSize
        ) -> NSRect {
            let parentFrame = parentWindow.frame
            return NSRect(
                x: parentFrame.minX + (parentFrame.width - contentSize.width) / 2 - QuickSearchPanelMetrics.shadowOutset,
                y: parentFrame.maxY - panelTopY - contentSize.height - QuickSearchPanelMetrics.shadowOutset,
                width: windowSize.width,
                height: windowSize.height
            )
        }
        private func collectionBehavior(parentWindow: NSWindow) -> NSWindow.CollectionBehavior {
            var behavior: NSWindow.CollectionBehavior = [
                .fullScreenAuxiliary,
                .stationary,
                .transient,
                .ignoresCycle
            ]
            if parentWindow.collectionBehavior.contains(.canJoinAllSpaces) {
                behavior.insert(.canJoinAllSpaces)
            }
            return behavior
        }
    }
}
private final class QuickSearchPanel: NSPanel {
    override var canBecomeKey: Bool { true }
    override var canBecomeMain: Bool { false }
    override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect {
        frameRect
    }
}
final class QuickSearchPanelAnchorView: NSView {
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        setup()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    private func setup() {
        wantsLayer = true
        layer?.backgroundColor = NSColor.clear.cgColor
    }
}
struct QuickSearchPanelConfiguration {
    let panelTopY: CGFloat
    let windowSize: NSSize
    let contentSize: NSSize
    let rootView: AnyView
}
private struct QuickSearchPanelWindowContent: View {
    let windowSize: NSSize
    let contentSize: NSSize
    let content: AnyView
    var body: some View {
        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)
    }
}
struct QuickSearchOverlayView: View {
    @Binding var query: String
    let results: [QuickSearchResult]
@@ -744,13 +1009,17 @@
    @Environment(\.colorScheme) private var colorScheme
    private let panelWidth: CGFloat = 760
    private let rowHeight: CGFloat = 74
    private let panelWidth: CGFloat = QuickSearchPanelMetrics.width
    private let rowHeight: CGFloat = QuickSearchPanelMetrics.rowHeight
    private var panelBackgroundColor: Color {
        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 {
@@ -806,19 +1075,24 @@
                    onHover: onHover,
                    onLaunch: onLaunch
                )
                .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20)
                .frame(
                    height: CGFloat(min(results.count, maxVisibleRows))
                        * (rowHeight + QuickSearchPanelMetrics.rowSpacing)
                        + QuickSearchPanelMetrics.resultListVerticalInset * 2
                )
            }
        }
        .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"))
    }
@@ -1023,6 +1297,18 @@
}
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: "")
@@ -1082,25 +1368,44 @@
            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)
@@ -1109,6 +1414,11 @@
            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() {
@@ -1135,7 +1445,9 @@
    override func mouseDown(with event: NSEvent) {
        if let result {
            onLaunch(result)
            DispatchQueue.main.async { [onLaunch] in
                onLaunch(result)
            }
        } else {
            super.mouseDown(with: event)
        }
@@ -1176,6 +1488,9 @@
        tagLabel.lineBreakMode = .byTruncatingTail
        tagLabel.maximumNumberOfLines = 1
        tagLabel.backgroundColor = .clear
        tagLabel.allowsDefaultTighteningForTruncation = true
        tagLabel.cell?.usesSingleLineMode = true
        tagLabel.cell?.lineBreakMode = .byTruncatingTail
    }
    private func updateColors() {
@@ -1274,11 +1589,17 @@
    }
    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)
            }
        }
    }
@@ -1328,6 +1649,8 @@
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: