From f5a1588c666e9f414e51eae344ed5153ed064191 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 29 May 2026 02:44:02 +0800
Subject: [PATCH] AppKit quick search result list
---
Apptag/QuickSearch.swift | 794 ++++++++++++++++++++++++++++++++++++++++++--------------
1 files changed, 589 insertions(+), 205 deletions(-)
diff --git a/Apptag/QuickSearch.swift b/Apptag/QuickSearch.swift
index e94a87d..bac6813 100644
--- a/Apptag/QuickSearch.swift
+++ b/Apptag/QuickSearch.swift
@@ -202,6 +202,64 @@
// MARK: - Search Documents
+private enum QuickSearchFieldKind: Int {
+ case name = 0
+ case tag = 1
+ case note = 2
+ case bundleIdentifier = 3
+ case internalBundleName = 4
+
+ var weight: Double {
+ switch self {
+ case .name: return 100
+ case .tag: return 70
+ case .note: return 45
+ case .internalBundleName: return 30
+ case .bundleIdentifier: return 20
+ }
+ }
+}
+
+private enum QuickSearchMatchKind {
+ case exact
+ case prefix
+ case substring
+ case acronym
+ case fuzzy
+
+ var weight: Double {
+ switch self {
+ case .exact: return 100
+ case .prefix: return 80
+ case .substring: return 60
+ case .acronym: return 55
+ case .fuzzy: return 35
+ }
+ }
+}
+
+private struct QuickSearchIndexedField {
+ let kind: QuickSearchFieldKind
+ let text: String
+ let normalized: String
+ let acronym: String
+ let pinyinCandidates: [String]
+ let allowPinyinSubstring: Bool
+ let allowPinyinFuzzySubsequence: Bool
+}
+
+private struct QuickSearchMatchOptions {
+ let allowSubstring: Bool
+ let allowFuzzySubsequence: Bool
+}
+
+private struct QuickSearchTokenMatch {
+ let score: Double
+ let fieldRank: Int
+ let fieldKind: QuickSearchFieldKind
+ let originalText: String
+}
+
struct QuickSearchDocument: Identifiable {
var id: URL { app.id }
let app: AppInfo
@@ -212,6 +270,7 @@
let bundleIdentifier: String
let lastOpenedAt: Date?
let openCount: Int
+ fileprivate let searchableFields: [QuickSearchIndexedField]
}
struct QuickSearchResult: Identifiable {
@@ -227,73 +286,30 @@
}
enum QuickSearchEngine {
- private enum FieldKind: Int {
- case name = 0
- case tag = 1
- case note = 2
- case bundleIdentifier = 3
- case internalBundleName = 4
-
- var weight: Double {
- switch self {
- case .name: return 100
- case .tag: return 70
- case .note: return 45
- case .internalBundleName: return 30
- case .bundleIdentifier: return 20
- }
- }
- }
-
- private enum MatchKind {
- case exact
- case prefix
- case substring
- case acronym
- case fuzzy
-
- var weight: Double {
- switch self {
- case .exact: return 100
- case .prefix: return 80
- case .substring: return 60
- case .acronym: return 55
- case .fuzzy: return 35
- }
- }
- }
-
- private struct Field {
- let kind: FieldKind
- let text: String
- let normalized: String
- let acronym: String
- let pinyinCandidates: [String]
- }
-
- private struct MatchOptions {
- let allowSubstring: Bool
- let allowFuzzySubsequence: Bool
- }
-
- private struct TokenMatch {
- let score: Double
- let fieldRank: Int
- let fieldKind: FieldKind
- let originalText: String
- }
-
static func makeDocuments(apps: [AppInfo], store: TagDatabase.Store) -> [QuickSearchDocument] {
apps.map { app in
- QuickSearchDocument(
- app: app,
- localizedNames: localizedNames(for: app),
- internalBundleNames: internalBundleNames(for: app),
+ 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: store.appNotes[app.path.path] ?? app.note ?? "",
- bundleIdentifier: app.bundleIdentifier ?? "",
+ 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
+ openCount: store.appOpenCounts[app.path.path] ?? 0,
+ searchableFields: searchableFields
)
}
}
@@ -320,7 +336,7 @@
}
private static func result(for document: QuickSearchDocument, tokens: [String]) -> QuickSearchResult? {
- let fields = searchableFields(for: document)
+ let fields = document.searchableFields
var textScore: Double = 0
var bestFieldRank = Int.max
var matchedTagName: String?
@@ -353,61 +369,78 @@
)
}
- private static func searchableFields(for document: QuickSearchDocument) -> [Field] {
- let names = uniqueOrdered([document.app.name] + document.localizedNames)
+ private static func makeSearchableFields(
+ appName: String,
+ localizedNames: [String],
+ internalBundleNames: [String],
+ tagNames: [String],
+ note: String,
+ bundleIdentifier: String
+ ) -> [QuickSearchIndexedField] {
+ let names = uniqueOrdered([appName] + localizedNames)
let nameFields = names.map {
- Field(
+ return QuickSearchIndexedField(
kind: .name,
text: $0,
normalized: normalizeField($0),
acronym: acronym(for: $0),
- pinyinCandidates: pinyinCandidates(for: $0)
+ pinyinCandidates: pinyinCandidates(for: $0, includeLatin: true),
+ allowPinyinSubstring: true,
+ allowPinyinFuzzySubsequence: true
)
}
- let tagFields = document.tagNames.map {
- Field(
+ let tagFields = tagNames.map {
+ QuickSearchIndexedField(
kind: .tag,
text: $0,
normalized: normalizeField($0),
acronym: "",
- pinyinCandidates: pinyinCandidates(for: $0)
+ pinyinCandidates: pinyinCandidates(for: $0),
+ allowPinyinSubstring: false,
+ allowPinyinFuzzySubsequence: false
)
}
- let noteFields = document.note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [
- Field(
+ let noteFields = note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [
+ QuickSearchIndexedField(
kind: .note,
- text: document.note,
- normalized: normalizeField(document.note),
+ text: note,
+ normalized: normalizeField(note),
acronym: "",
- pinyinCandidates: pinyinCandidates(for: document.note)
+ pinyinCandidates: pinyinCandidates(for: note),
+ allowPinyinSubstring: true,
+ allowPinyinFuzzySubsequence: false
)
]
- let bundleFields = document.bundleIdentifier.isEmpty ? [] : [
- Field(
+ let bundleFields = bundleIdentifier.isEmpty ? [] : [
+ QuickSearchIndexedField(
kind: .bundleIdentifier,
- text: document.bundleIdentifier,
- normalized: normalizeField(document.bundleIdentifier),
+ text: bundleIdentifier,
+ normalized: normalizeField(bundleIdentifier),
acronym: "",
- pinyinCandidates: []
+ pinyinCandidates: [],
+ allowPinyinSubstring: false,
+ allowPinyinFuzzySubsequence: false
)
]
- let internalBundleNameFields = document.internalBundleNames.map {
- Field(
+ let internalBundleNameFields = internalBundleNames.map {
+ QuickSearchIndexedField(
kind: .internalBundleName,
text: $0,
normalized: normalizeField($0),
acronym: "",
- pinyinCandidates: []
+ pinyinCandidates: [],
+ allowPinyinSubstring: false,
+ allowPinyinFuzzySubsequence: false
)
}
return nameFields + tagFields + noteFields + bundleFields + internalBundleNameFields
}
- private static func match(token: String, field: Field) -> TokenMatch? {
+ private static func match(token: String, field: QuickSearchIndexedField) -> QuickSearchTokenMatch? {
guard let candidate = bestMatchCandidate(token: token, field: field) else { return nil }
let positionBoost = candidate.0 == .exact ? 0 : max(0, 10 - min(candidate.1, 10))
let score = field.kind.weight + candidate.0.weight + Double(positionBoost)
- return TokenMatch(
+ return QuickSearchTokenMatch(
score: score,
fieldRank: field.kind.rawValue,
fieldKind: field.kind,
@@ -415,8 +448,8 @@
)
}
- private static func bestMatchCandidate(token: String, field: Field) -> (MatchKind, Int)? {
- var candidates: [(MatchKind, Int)] = []
+ private static func bestMatchCandidate(token: String, field: QuickSearchIndexedField) -> (QuickSearchMatchKind, Int)? {
+ var candidates: [(QuickSearchMatchKind, Int)] = []
if let textCandidate = matchCandidate(
token: token,
@@ -432,7 +465,8 @@
if let pinyinCandidate = matchPinyinCandidate(
token: token,
normalized: pinyin,
- allowSubstring: field.kind == .note
+ allowSubstring: field.allowPinyinSubstring,
+ allowFuzzySubsequence: field.allowPinyinFuzzySubsequence
) {
candidates.append(pinyinCandidate)
}
@@ -444,24 +478,24 @@
}
}
- private static func matchOptions(for fieldKind: FieldKind) -> MatchOptions {
+ private static func matchOptions(for fieldKind: QuickSearchFieldKind) -> QuickSearchMatchOptions {
switch fieldKind {
case .name:
- return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
+ return QuickSearchMatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
case .tag:
- return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
+ return QuickSearchMatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
case .note:
- return MatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
+ return QuickSearchMatchOptions(allowSubstring: true, allowFuzzySubsequence: true)
case .bundleIdentifier, .internalBundleName:
- return MatchOptions(allowSubstring: false, allowFuzzySubsequence: false)
+ return QuickSearchMatchOptions(allowSubstring: false, allowFuzzySubsequence: false)
}
}
private static func matchCandidate(
token: String,
normalized: String,
- options: MatchOptions
- ) -> (MatchKind, Int)? {
+ options: QuickSearchMatchOptions
+ ) -> (QuickSearchMatchKind, Int)? {
guard !normalized.isEmpty else { return nil }
if normalized == token {
return (.exact, 0)
@@ -481,8 +515,9 @@
private static func matchPinyinCandidate(
token: String,
normalized: String,
- allowSubstring: Bool = false
- ) -> (MatchKind, Int)? {
+ allowSubstring: Bool = false,
+ allowFuzzySubsequence: Bool = false
+ ) -> (QuickSearchMatchKind, Int)? {
guard !normalized.isEmpty else { return nil }
if normalized == token {
return (.exact, 0)
@@ -492,6 +527,9 @@
}
if allowSubstring, let range = normalized.range(of: token) {
return (.substring, normalized.distance(from: normalized.startIndex, to: range.lowerBound))
+ }
+ if allowFuzzySubsequence, token.count >= 3, isSubsequence(token, of: normalized) {
+ return (.fuzzy, 10)
}
return nil
}
@@ -566,8 +604,8 @@
.lowercased()
}
- private static func pinyinCandidates(for value: String) -> [String] {
- guard value.range(of: #"\p{Han}"#, options: .regularExpression) != nil else { return [] }
+ private static func pinyinCandidates(for value: String, includeLatin: Bool = false) -> [String] {
+ guard includeLatin || containsNonLatinLetter(value) else { return [] }
let mutable = NSMutableString(string: value)
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
@@ -585,6 +623,31 @@
.map(String.init)
.joined()
return uniqueOrdered([spaced, compact, initials].filter { !$0.isEmpty })
+ }
+
+ private static func containsNonLatinLetter(_ value: String) -> Bool {
+ value.unicodeScalars.contains { scalar in
+ CharacterSet.letters.contains(scalar) && !isLatinScriptLetter(scalar)
+ }
+ }
+
+ private static func isLatinScriptLetter(_ scalar: UnicodeScalar) -> Bool {
+ switch scalar.value {
+ case 0x0041...0x005A, // Basic Latin uppercase
+ 0x0061...0x007A, // Basic Latin lowercase
+ 0x00AA,
+ 0x00BA,
+ 0x00C0...0x024F, // Latin-1 Supplement, Extended-A/B
+ 0x1E00...0x1EFF, // Latin Extended Additional
+ 0x2C60...0x2C7F, // Latin Extended-C
+ 0xA720...0xA7FF, // Latin Extended-D
+ 0xAB30...0xAB6F, // Latin Extended-E
+ 0xFF21...0xFF3A, // Fullwidth Latin uppercase
+ 0xFF41...0xFF5A: // Fullwidth Latin lowercase
+ return true
+ default:
+ return false
+ }
}
private static func isSubsequence(_ token: String, of value: String) -> Bool {
@@ -671,6 +734,7 @@
let results: [QuickSearchResult]
let selectedID: URL?
let focusToken: Int
+ let selectionScrollToken: Int
let isLoading: Bool
let maxVisibleRows: Int
let errorMessage: String?
@@ -678,8 +742,16 @@
let onHover: (QuickSearchResult) -> Void
let onLaunch: (QuickSearchResult) -> Void
+ @Environment(\.colorScheme) private var colorScheme
+
private let panelWidth: CGFloat = 760
private let rowHeight: CGFloat = 74
+
+ private var panelBackgroundColor: Color {
+ colorScheme == .dark
+ ? Color(red: 0.105, green: 0.110, blue: 0.125).opacity(0.97)
+ : Color.white.opacity(0.97)
+ }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -704,17 +776,11 @@
Divider().opacity(0.35)
if isLoading {
- HStack(spacing: 10) {
- ProgressView()
- .controlSize(.small)
- Text(tr("quickSearch.loading"))
- .font(.system(size: 15, weight: .medium))
- .foregroundStyle(.secondary)
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 28)
- .padding(.vertical, 24)
- .frame(minHeight: 86)
+ QuickSearchMessageRow(
+ systemImage: "hourglass",
+ message: tr("quickSearch.loading"),
+ tint: .secondary
+ )
} else if let errorMessage {
QuickSearchMessageRow(
systemImage: "exclamationmark.triangle.fill",
@@ -730,42 +796,23 @@
tint: .secondary
)
} else {
- ScrollViewReader { scrollProxy in
- ScrollView(.vertical, showsIndicators: results.count > maxVisibleRows) {
- LazyVStack(spacing: 2) {
- ForEach(results) { result in
- QuickSearchResultRow(
- result: result,
- isSelected: result.id == selectedID
- )
- .frame(height: rowHeight)
- .id(result.id)
- .contentShape(Rectangle())
- .onHover { hovering in
- if hovering { onHover(result) }
- }
- .onTapGesture {
- onLaunch(result)
- }
- }
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 10)
- }
- .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20)
- .onChange(of: selectedID) { _, id in
- guard let id else { return }
- withAnimation(.easeOut(duration: 0.08)) {
- scrollProxy.scrollTo(id, anchor: .center)
- }
- }
- }
+ QuickSearchResultListView(
+ results: results,
+ selectedID: selectedID,
+ selectionScrollToken: selectionScrollToken,
+ maxVisibleRows: maxVisibleRows,
+ rowHeight: rowHeight,
+ isDarkMode: colorScheme == .dark,
+ onHover: onHover,
+ onLaunch: onLaunch
+ )
+ .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20)
}
}
.frame(width: panelWidth)
.background(
RoundedRectangle(cornerRadius: 34, style: .continuous)
- .fill(.regularMaterial)
+ .fill(panelBackgroundColor)
.shadow(color: .black.opacity(0.18), radius: 36, y: 18)
)
.overlay(
@@ -777,66 +824,373 @@
}
}
-private struct QuickSearchResultRow: View {
- let result: QuickSearchResult
- let isSelected: Bool
- @Environment(\.colorScheme) private var colorScheme
+private struct QuickSearchResultListView: NSViewRepresentable {
+ let results: [QuickSearchResult]
+ let selectedID: URL?
+ let selectionScrollToken: Int
+ let maxVisibleRows: Int
+ let rowHeight: CGFloat
+ let isDarkMode: Bool
+ let onHover: (QuickSearchResult) -> Void
+ let onLaunch: (QuickSearchResult) -> Void
- var body: some View {
- HStack(spacing: 16) {
- Image(nsImage: result.app.icon)
- .resizable()
- .frame(width: 46, height: 46)
- .cornerRadius(10)
-
- VStack(alignment: .leading, spacing: 4) {
- Text(result.app.name)
- .font(.system(size: 20, weight: .semibold))
- .foregroundStyle(.primary)
- .lineLimit(1)
- .truncationMode(.tail)
-
- if let detailText {
- Text(detailText)
- .font(.system(size: 16, weight: .medium))
- .foregroundStyle(Color.primary.opacity(0.38))
- .lineLimit(1)
- .truncationMode(.tail)
- }
- }
- .layoutPriority(1)
-
- Spacer(minLength: 8)
-
- if let tagName = rightTagName {
- Text(tagName)
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(Color.primary.opacity(0.46))
- .lineLimit(1)
- .truncationMode(.tail)
- .padding(.horizontal, 12)
- .frame(height: 32)
- .frame(maxWidth: 128)
- .background(
- Capsule(style: .continuous)
- .fill(Color.primary.opacity(colorScheme == .dark ? 0.12 : 0.07))
- )
- }
- }
- .padding(.horizontal, 18)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(isSelected ? selectedFill : Color.clear)
+ func makeNSView(context: Context) -> QuickSearchResultListHostView {
+ let view = QuickSearchResultListHostView()
+ view.update(
+ results: results,
+ selectedID: selectedID,
+ selectionScrollToken: selectionScrollToken,
+ maxVisibleRows: maxVisibleRows,
+ rowHeight: rowHeight,
+ isDarkMode: isDarkMode,
+ onHover: onHover,
+ onLaunch: onLaunch
)
- .accessibilityLabel(accessibilityText)
+ return view
}
- private var selectedFill: Color {
- colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.075)
+ func updateNSView(_ view: QuickSearchResultListHostView, context: Context) {
+ view.update(
+ results: results,
+ selectedID: selectedID,
+ selectionScrollToken: selectionScrollToken,
+ maxVisibleRows: maxVisibleRows,
+ rowHeight: rowHeight,
+ isDarkMode: isDarkMode,
+ onHover: onHover,
+ onLaunch: onLaunch
+ )
+ }
+}
+
+final class QuickSearchResultListHostView: NSView {
+ private let scrollView = NSScrollView()
+ private let documentView = QuickSearchResultListDocumentView()
+ private var selectedID: URL?
+ private var selectionScrollToken = 0
+ private var didInstall = false
+
+ override var isFlipped: Bool { true }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ setup()
}
- private var detailText: String? {
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setup()
+ }
+
+ func update(
+ results: [QuickSearchResult],
+ selectedID: URL?,
+ selectionScrollToken: Int,
+ maxVisibleRows: Int,
+ rowHeight: CGFloat,
+ isDarkMode: Bool,
+ onHover: @escaping (QuickSearchResult) -> Void,
+ onLaunch: @escaping (QuickSearchResult) -> Void
+ ) {
+ let shouldScrollSelection = selectionScrollToken != self.selectionScrollToken
+ self.selectedID = selectedID
+ self.selectionScrollToken = selectionScrollToken
+ scrollView.hasVerticalScroller = results.count > maxVisibleRows
+ documentView.update(
+ results: results,
+ selectedID: selectedID,
+ rowHeight: rowHeight,
+ isDarkMode: isDarkMode,
+ onHover: onHover,
+ onLaunch: onLaunch
+ )
+ needsLayout = true
+ layoutSubtreeIfNeeded()
+ if shouldScrollSelection, let selectedID {
+ scrollResultToCenter(selectedID)
+ }
+ }
+
+ override func layout() {
+ super.layout()
+ scrollView.frame = bounds
+ let visibleSize = scrollView.contentView.bounds.size
+ documentView.frame = NSRect(
+ origin: .zero,
+ size: NSSize(
+ width: max(visibleSize.width, bounds.width),
+ height: documentView.preferredHeight
+ )
+ )
+ documentView.needsLayout = true
+ }
+
+ private func setup() {
+ guard !didInstall else { return }
+ didInstall = true
+ wantsLayer = true
+ layer?.backgroundColor = NSColor.clear.cgColor
+
+ scrollView.drawsBackground = false
+ scrollView.borderType = .noBorder
+ scrollView.autohidesScrollers = true
+ scrollView.scrollerStyle = .overlay
+ scrollView.hasHorizontalScroller = false
+ scrollView.hasVerticalScroller = false
+ scrollView.documentView = documentView
+ addSubview(scrollView)
+ }
+
+ private func scrollResultToCenter(_ id: URL) {
+ guard let frame = documentView.frameForResult(id) else { return }
+ let visibleBounds = scrollView.contentView.bounds
+ let maxY = max(0, documentView.bounds.height - visibleBounds.height)
+ let targetY = min(max(0, frame.midY - visibleBounds.height / 2), maxY)
+ scrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY))
+ scrollView.reflectScrolledClipView(scrollView.contentView)
+ }
+}
+
+final class QuickSearchResultListDocumentView: NSView {
+ private var rowViews: [QuickSearchResultRowView] = []
+ private var rowIDs: [URL] = []
+ private var rowHeight: CGFloat = 74
+ private let rowSpacing: CGFloat = 2
+ private let horizontalInset: CGFloat = 10
+ private let verticalInset: CGFloat = 10
+
+ override var isFlipped: Bool { true }
+
+ var preferredHeight: CGFloat {
+ verticalInset * 2
+ + CGFloat(rowViews.count) * rowHeight
+ + CGFloat(max(0, rowViews.count - 1)) * rowSpacing
+ }
+
+ func update(
+ results: [QuickSearchResult],
+ selectedID: URL?,
+ rowHeight: CGFloat,
+ isDarkMode: Bool,
+ onHover: @escaping (QuickSearchResult) -> Void,
+ onLaunch: @escaping (QuickSearchResult) -> Void
+ ) {
+ self.rowHeight = rowHeight
+ let nextIDs = results.map(\.id)
+ if nextIDs != rowIDs {
+ rebuildRows(for: results)
+ rowIDs = nextIDs
+ }
+
+ for (index, row) in rowViews.enumerated() {
+ guard index < results.count else { continue }
+ row.configure(
+ result: results[index],
+ isSelected: results[index].id == selectedID,
+ isDarkMode: isDarkMode,
+ onHover: onHover,
+ onLaunch: onLaunch
+ )
+ }
+ needsLayout = true
+ }
+
+ func frameForResult(_ id: URL) -> NSRect? {
+ guard let index = rowIDs.firstIndex(of: id),
+ index < rowViews.count
+ else { return nil }
+ return rowViews[index].frame
+ }
+
+ override func layout() {
+ super.layout()
+ let width = max(0, bounds.width - horizontalInset * 2)
+ var y = verticalInset
+ for row in rowViews {
+ row.frame = NSRect(x: horizontalInset, y: y, width: width, height: rowHeight)
+ y += rowHeight + rowSpacing
+ }
+ }
+
+ private func rebuildRows(for results: [QuickSearchResult]) {
+ rowViews.forEach { $0.removeFromSuperview() }
+ rowViews = results.map { _ in
+ let row = QuickSearchResultRowView()
+ addSubview(row)
+ return row
+ }
+ }
+}
+
+final class QuickSearchResultRowView: NSView {
+ private let iconView = NSImageView()
+ private let titleLabel = NSTextField(labelWithString: "")
+ private let detailLabel = NSTextField(labelWithString: "")
+ private let tagBackgroundView = NSView()
+ private let tagLabel = NSTextField(labelWithString: "")
+ private var trackingAreaRef: NSTrackingArea?
+ private var result: QuickSearchResult?
+ private var isSelected = false
+ private var isDarkMode = false
+ private var onHover: (QuickSearchResult) -> Void = { _ in }
+ private var onLaunch: (QuickSearchResult) -> Void = { _ in }
+
+ override var isFlipped: Bool { true }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ setup()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setup()
+ }
+
+ func configure(
+ result: QuickSearchResult,
+ isSelected: Bool,
+ isDarkMode: Bool,
+ onHover: @escaping (QuickSearchResult) -> Void,
+ onLaunch: @escaping (QuickSearchResult) -> Void
+ ) {
+ self.result = result
+ self.isSelected = isSelected
+ self.isDarkMode = isDarkMode
+ self.onHover = onHover
+ self.onLaunch = onLaunch
+
+ iconView.image = result.app.icon
+ titleLabel.stringValue = result.app.displayName
+ detailLabel.stringValue = detailText(for: result) ?? ""
+ detailLabel.isHidden = detailLabel.stringValue.isEmpty
+ tagLabel.stringValue = rightTagName(for: result) ?? ""
+ tagBackgroundView.isHidden = tagLabel.stringValue.isEmpty
+ setAccessibilityLabel(accessibilityText(for: result))
+ updateColors()
+ needsLayout = true
+ }
+
+ override func layout() {
+ super.layout()
+ let iconSize: CGFloat = 46
+ let iconX: CGFloat = 18
+ iconView.frame = NSRect(
+ x: iconX,
+ y: (bounds.height - iconSize) / 2,
+ width: iconSize,
+ height: iconSize
+ )
+
+ let tagMaxWidth: CGFloat = 128
+ let tagHeight: CGFloat = 32
+ let trailingInset: CGFloat = 18
+ var tagFrame = NSRect.zero
+ if !tagBackgroundView.isHidden {
+ let labelWidth = min(tagMaxWidth - 24, max(22, tagLabel.intrinsicContentSize.width))
+ let tagWidth = min(tagMaxWidth, labelWidth + 24)
+ tagFrame = NSRect(
+ x: bounds.width - trailingInset - tagWidth,
+ y: (bounds.height - tagHeight) / 2,
+ width: tagWidth,
+ height: tagHeight
+ )
+ tagBackgroundView.frame = tagFrame
+ tagLabel.frame = NSRect(x: 12, y: 6, width: tagWidth - 24, height: 20)
+ }
+
+ let textX = iconX + iconSize + 16
+ let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - 16
+ let textWidth = max(40, textRight - textX)
+ if detailLabel.isHidden {
+ titleLabel.frame = NSRect(x: textX, y: (bounds.height - 26) / 2, width: textWidth, height: 26)
+ detailLabel.frame = .zero
+ } else {
+ titleLabel.frame = NSRect(x: textX, y: 14, width: textWidth, height: 25)
+ detailLabel.frame = NSRect(x: textX, y: 42, width: textWidth, height: 21)
+ }
+ }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let trackingAreaRef {
+ removeTrackingArea(trackingAreaRef)
+ }
+ let area = NSTrackingArea(
+ rect: bounds,
+ options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(area)
+ trackingAreaRef = area
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ super.mouseEntered(with: event)
+ if let result {
+ onHover(result)
+ }
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ if let result {
+ onLaunch(result)
+ } else {
+ super.mouseDown(with: event)
+ }
+ }
+
+ private func setup() {
+ wantsLayer = true
+ layer?.cornerRadius = 18
+ layer?.cornerCurve = .continuous
+
+ iconView.imageScaling = .scaleProportionallyUpOrDown
+ iconView.wantsLayer = true
+ iconView.layer?.cornerRadius = 10
+ iconView.layer?.cornerCurve = .continuous
+ iconView.layer?.masksToBounds = true
+ addSubview(iconView)
+
+ titleLabel.font = NSFont.systemFont(ofSize: 20, weight: .semibold)
+ titleLabel.lineBreakMode = .byTruncatingTail
+ titleLabel.maximumNumberOfLines = 1
+ titleLabel.backgroundColor = .clear
+ addSubview(titleLabel)
+
+ detailLabel.font = NSFont.systemFont(ofSize: 16, weight: .medium)
+ detailLabel.lineBreakMode = .byTruncatingTail
+ detailLabel.maximumNumberOfLines = 1
+ detailLabel.backgroundColor = .clear
+ addSubview(detailLabel)
+
+ tagBackgroundView.wantsLayer = true
+ tagBackgroundView.layer?.cornerRadius = 16
+ tagBackgroundView.layer?.cornerCurve = .continuous
+ tagBackgroundView.addSubview(tagLabel)
+ addSubview(tagBackgroundView)
+
+ tagLabel.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
+ tagLabel.alignment = .center
+ tagLabel.lineBreakMode = .byTruncatingTail
+ tagLabel.maximumNumberOfLines = 1
+ tagLabel.backgroundColor = .clear
+ }
+
+ private func updateColors() {
+ layer?.backgroundColor = isSelected
+ ? NSColor.labelColor.withAlphaComponent(isDarkMode ? 0.14 : 0.075).cgColor
+ : NSColor.clear.cgColor
+ titleLabel.textColor = .labelColor
+ detailLabel.textColor = NSColor.labelColor.withAlphaComponent(0.38)
+ tagLabel.textColor = NSColor.labelColor.withAlphaComponent(0.46)
+ tagBackgroundView.layer?.backgroundColor = NSColor.labelColor
+ .withAlphaComponent(isDarkMode ? 0.12 : 0.07)
+ .cgColor
+ }
+
+ private func detailText(for result: QuickSearchResult) -> String? {
if let note = result.noteSnippet, !note.isEmpty {
return note
}
@@ -847,14 +1201,14 @@
return nil
}
- private var rightTagName: String? {
+ private func rightTagName(for result: QuickSearchResult) -> String? {
let tag = result.matchedTagName ?? result.document.tagNames.first
guard let tag, !tag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
return tag
}
- private var accessibilityText: String {
- [result.app.name, detailText].compactMap { $0 }.joined(separator: ", ")
+ private func accessibilityText(for result: QuickSearchResult) -> String {
+ [result.app.displayName, detailText(for: result)].compactMap { $0 }.joined(separator: ", ")
}
}
@@ -899,11 +1253,10 @@
field.placeholderString = placeholder
field.delegate = context.coordinator
field.onCommand = onCommand
+ context.coordinator.onCommand = onCommand
field.setAccessibilityLabel(tr("quickSearch.inputAccessibility"))
context.coordinator.field = field
- DispatchQueue.main.async {
- field.window?.makeFirstResponder(field)
- }
+ requestFocus(field)
return field
}
@@ -913,11 +1266,19 @@
}
field.placeholderString = placeholder
field.onCommand = onCommand
+ context.coordinator.onCommand = onCommand
if context.coordinator.lastFocusToken != focusToken {
context.coordinator.lastFocusToken = focusToken
- DispatchQueue.main.async {
- field.window?.makeFirstResponder(field)
- }
+ requestFocus(field)
+ }
+ }
+
+ private func requestFocus(_ field: QuickSearchNativeTextField) {
+ DispatchQueue.main.async { [weak field] in
+ guard let field,
+ field.window?.firstResponder !== field
+ else { return }
+ field.window?.makeFirstResponder(field)
}
}
@@ -928,6 +1289,7 @@
final class Coordinator: NSObject, NSTextFieldDelegate {
var text: Binding<String>
var lastFocusToken = 0
+ var onCommand: ((QuickSearchCommand) -> Void)?
weak var field: NSTextField?
init(text: Binding<String>) {
@@ -938,6 +1300,28 @@
guard let field = obj.object as? NSTextField else { return }
text.wrappedValue = field.stringValue
}
+
+ func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+ switch commandSelector {
+ case #selector(NSResponder.moveUp(_:)):
+ onCommand?(.moveUp)
+ return true
+ case #selector(NSResponder.moveDown(_:)):
+ onCommand?(.moveDown)
+ return true
+ case #selector(NSResponder.insertNewline(_:)):
+ onCommand?(.submit)
+ return true
+ case #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)):
+ onCommand?(.submit)
+ return true
+ case #selector(NSResponder.cancelOperation(_:)):
+ onCommand?(.dismiss)
+ return true
+ default:
+ return false
+ }
+ }
}
}
--
Gitblit v1.9.3