Ariver
2026-05-20 7c97841d07d4a596508f663c887d1f67d105faf6
perf: reduce launcher scroll and search jank
4 files modified
434 ■■■■ changed files
Apptag/AppGridItem.swift 4 ●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 267 ●●●●● patch | view | raw | blame | history
Apptag/QuickSearch.swift 157 ●●●●● patch | view | raw | blame | history
CHANGELOG.md 6 ●●●● patch | view | raw | blame | history
Apptag/AppGridItem.swift
@@ -326,15 +326,19 @@
    }
    func updateNSView(_ view: DragIconNSView, context: Context) {
        let imageChanged = view.image !== icon
        let sizeChanged = view.iconSize != iconSize
        view.image = icon
        view.iconSize = iconSize
        view.payload = payload
        view.onLongPress = onLongPress
        view.onDragEnd = onDragEnd
        view.onClick = onClick
        if imageChanged || sizeChanged {
        view.needsDisplay = true
    }
}
}
private final class DragIconNSView: NSView {
    var image: NSImage = NSImage()
Apptag/ContentView.swift
@@ -340,8 +340,13 @@
    private let initialQuickSearchSource: String?
    @State private var allApps: [AppInfo] = []
    @State private var displayGroups: [TagGroup] = []
    @State private var tagColors: [String: Int] = [:]
    @State private var scrollProxy: ScrollViewProxy? = nil
    @StateObject private var scrollInteractionState = AppGridScrollInteractionState()
    @State private var groupLayoutVersion = 0
    @State private var cachedGridContainerRowsKey: GridContainerRowsKey? = nil
    @State private var cachedGridContainerRows: [GridContainerLayoutRow] = []
    // Edit mode
    @State private var editPhase: EditPhase = .none
@@ -413,7 +418,7 @@
    private let floatingControlsTrailingInset: CGFloat = 20
    private let floatingControlsReservedWidth: CGFloat = 120
    private var appBubbleDisabled: Bool {
        appDragModeActive || pendingUncategorizedDrop != nil
        appDragModeActive || pendingUncategorizedDrop != nil || scrollInteractionState.isFrozen
    }
    private let rightSidebarFloatingClearance: CGFloat = 44
@@ -510,7 +515,7 @@
        }
        .onAppear {
            refreshNotchHeight()
            refreshApps()
            refreshAppsIfNeeded()
            if let initialQuickSearchSource, !initialQuickSearchConsumed {
                initialQuickSearchConsumed = true
                quickSearchCloseHidesOverlay = initialQuickSearchSource == QuickSearchOpenSource.globalHidden
@@ -526,7 +531,11 @@
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
            resetTransientDragState()
            refreshNotchHeight()
            if quickSearchVisible {
                refreshAppsIfNeeded()
            } else {
            refreshApps()
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
            resetTransientDragState()
@@ -568,6 +577,12 @@
            if editingBubble != nil && !focused {
                commitBubbleNote()
            }
        }
        .onChange(of: draggedTagNames) { _, newOrder in
            rebuildDisplayGroups(apps: allApps, tagOrder: newOrder)
        }
        .onChange(of: defaultGroupName) { _, _ in
            rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
        }
        .onChange(of: quickSearchQuery) { _, _ in
            quickSearchErrorMessage = nil
@@ -1065,7 +1080,7 @@
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 24) {
                    ForEach(groups) { group in
                    ForEach(displayGroups) { group in
                        TagGroupView(
                            group: group,
                            onSelectApp: { app in openApp(app) },
@@ -1084,7 +1099,9 @@
                            }
                        ).id(group.id)
                    }
                }.padding(20)
                }
                .padding(20)
                .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
            }
            .id(displayMode)  // force rebuild on mode switch
            .onAppear { scrollProxy = proxy }
@@ -1100,7 +1117,7 @@
            let colCount = max(1, Int((available + gap) / (colW + gap)))
            let actualColW = (available - gap * CGFloat(colCount - 1)) / CGFloat(colCount)
            let columns = distributeToColumns(groups: groups, colCount: colCount, colWidth: actualColW)
            let columns = distributeToColumns(groups: displayGroups, colCount: colCount, colWidth: actualColW)
            ScrollViewReader { proxy in
                ScrollView {
@@ -1115,6 +1132,7 @@
                        }
                    }
                    .padding(outerPad)
                    .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
                }
                .id(displayMode)  // force rebuild on mode switch
                .onAppear { scrollProxy = proxy }
@@ -1217,6 +1235,12 @@
        .animation(.easeOut(duration: 0.045), value: isHovered)
        .animation(.easeOut(duration: 0.08), value: isColorlessFilled)
        .onHover { hovering in
            guard !scrollInteractionState.isFrozen else {
                if !hovering && hoveredContainer == group.name {
                    hoveredContainer = nil
                }
                return
            }
            if isColored || isColorless {
                hoveredContainer = hovering ? group.name : nil
            }
@@ -1238,11 +1262,23 @@
            let gap: CGFloat = 16
            let available = geo.size.width - outerPad * 2
            let preferredCount = preferredGridContainersPerRow(availableWidth: available)
            let rows = gridContainerRows(groups: groups, trackCount: preferredCount, availableWidth: available, gap: gap)
            let rowsKey = GridContainerRowsKey(
                groupLayoutVersion: groupLayoutVersion,
                trackCount: preferredCount,
                availableWidth: Int(available.rounded()),
                iconSize: Int(iconSize.rounded())
            )
            let rows = gridContainerRowsForRender(
                key: rowsKey,
                groups: displayGroups,
                trackCount: preferredCount,
                availableWidth: available,
                gap: gap
            )
            ScrollViewReader { proxy in
                ScrollView {
                    VStack(alignment: .leading, spacing: gap) {
                    LazyVStack(alignment: .leading, spacing: gap) {
                        ForEach(Array(rows.indices), id: \.self) { rowIndex in
                            let row = rows[rowIndex]
@@ -1257,9 +1293,28 @@
                    }
                    .padding(outerPad)
                    .frame(maxWidth: .infinity, alignment: .topLeading)
                    .background(AppGridScrollActivityObserver(onScroll: handleAppGridScrollActivity))
                }
                .id(displayMode)
                .onAppear { scrollProxy = proxy }
                .onAppear {
                    updateCachedGridContainerRows(
                        key: rowsKey,
                        groups: displayGroups,
                        trackCount: preferredCount,
                        availableWidth: available,
                        gap: gap
                    )
                }
                .onChange(of: rowsKey) { _, newKey in
                    updateCachedGridContainerRows(
                        key: newKey,
                        groups: displayGroups,
                        trackCount: preferredCount,
                        availableWidth: available,
                        gap: gap
                    )
                }
            }
        }
    }
@@ -1281,10 +1336,52 @@
        let fixedRows: Int
    }
    private struct GridContainerRowsKey: Equatable {
        let groupLayoutVersion: Int
        let trackCount: Int
        let availableWidth: Int
        let iconSize: Int
    }
    private struct GridContainerCandidate {
        let spans: [Int]
        let rows: Int
        let cost: CGFloat
    }
    private func gridContainerRowsForRender(
        key: GridContainerRowsKey,
        groups: [TagGroup],
        trackCount: Int,
        availableWidth: CGFloat,
        gap: CGFloat
    ) -> [GridContainerLayoutRow] {
        if cachedGridContainerRowsKey == key {
            return cachedGridContainerRows
        }
        return gridContainerRows(
            groups: groups,
            trackCount: trackCount,
            availableWidth: availableWidth,
            gap: gap
        )
    }
    private func updateCachedGridContainerRows(
        key: GridContainerRowsKey,
        groups: [TagGroup],
        trackCount: Int,
        availableWidth: CGFloat,
        gap: CGFloat
    ) {
        guard cachedGridContainerRowsKey != key else { return }
        cachedGridContainerRows = gridContainerRows(
            groups: groups,
            trackCount: trackCount,
            availableWidth: availableWidth,
            gap: gap
        )
        cachedGridContainerRowsKey = key
    }
    private func gridContainerRows(groups: [TagGroup], trackCount: Int, availableWidth: CGFloat, gap: CGFloat) -> [GridContainerLayoutRow] {
@@ -1469,6 +1566,12 @@
        .animation(.easeOut(duration: 0.045), value: isHovered)
        .animation(.easeOut(duration: 0.08), value: isColorlessFilled)
        .onHover { hovering in
            guard !scrollInteractionState.isFrozen else {
                if !hovering && hoveredContainer == group.name {
                    hoveredContainer = nil
                }
                return
            }
            if isColored || isColorlessGrid {
                hoveredContainer = hovering ? group.name : nil
            } else if hovering {
@@ -1995,14 +2098,20 @@
            let result = SmartStartService.applySuggestion(draft)
            let store = result.store
            let apps = TagEditor.annotate(apps: scannedApps, store: store)
            let quickSearchDocs = QuickSearchEngine.makeDocuments(apps: apps, store: store)
            let colors = store.tags.mapValues { $0.color }
            let order = TagEditor.orderedTagNames()
            DispatchQueue.main.async {
                pendingSmartStartDraft = nil
                allApps = apps
                quickSearchDocuments = quickSearchDocs
                tagColors = colors
                draggedTagNames = order
                rebuildDisplayGroups(apps: apps, tagOrder: order)
                if quickSearchVisible {
                    refreshQuickSearchResults()
                }
                if let summary = result.summary {
                    showSmartStartNotice(mode: .manuallyApplied, summary: summary)
                } else {
@@ -2061,17 +2170,10 @@
        tagName == TagDatabase.uncommonTagKey ? tr("group.uncommon") : tagName
    }
    private var tagLabels: [TagLabel] {
        groups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
    }
    private var groups: [TagGroup] {
        let order = draggedTagNames.isEmpty
            ? TagEditor.orderedTagNames()
            : draggedTagNames
        let raw = AppIndexer.group(apps: allApps, defaultGroupName: defaultGroupName, tagOrder: order)
        // Translate stable keys for display
        return raw.map { group in
    private func makeDisplayGroups(apps: [AppInfo], tagOrder: [String]) -> [TagGroup] {
        let order = tagOrder.isEmpty ? TagEditor.orderedTagNames() : tagOrder
        let rawGroups = AppIndexer.group(apps: apps, defaultGroupName: defaultGroupName, tagOrder: order)
        return rawGroups.map { group in
            if group.name == defaultGroupName {
                return TagGroup(name: tr("group.uncategorized"), apps: group.apps)
            }
@@ -2082,8 +2184,18 @@
        }
    }
    private func rebuildDisplayGroups(apps: [AppInfo], tagOrder: [String]) {
        displayGroups = makeDisplayGroups(apps: apps, tagOrder: tagOrder)
        groupLayoutVersion &+= 1
        cachedGridContainerRowsKey = nil
    }
    private var tagLabels: [TagLabel] {
        displayGroups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
    }
    private var editGroups: [TagGroup] {
        groups + [
        displayGroups + [
            TagGroup(
                name: tr("group.uncommon"),
                apps: allApps.filter(\.isUncommon).sorted {
@@ -2095,12 +2207,26 @@
    // MARK: - Actions
    private func refreshAppsIfNeeded() {
        guard allApps.isEmpty, !refreshInProgress else { return }
        refreshApps()
    }
    private func refreshNotchHeight() {
        let mousePoint = NSEvent.mouseLocation
        let activeScreen = NSScreen.screens.first(where: {
            NSMouseInRect(mousePoint, $0.frame, false)
        }) ?? NSScreen.main
        notchHeight = activeScreen?.safeAreaInsets.top ?? 0
    }
    private func handleAppGridScrollActivity() {
        if !scrollInteractionState.isFrozen {
            hoveredAppItemID = nil
            hoveredBubble = nil
            hoveredContainer = nil
        }
        scrollInteractionState.noteScroll()
    }
    private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
@@ -2500,6 +2626,7 @@
    private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) {
        AppDragCoordinator.shared.cancelDrag()
        scrollInteractionState.reset()
        appDragModeActive = false
        appDragResetToken &+= 1
        tagNavDragModeActive = false
@@ -2542,6 +2669,7 @@
                quickSearchDocuments = quickSearchDocs
                tagColors = colors
                draggedTagNames = order
                rebuildDisplayGroups(apps: apps, tagOrder: order)
                if quickSearchVisible {
                    refreshQuickSearchResults()
                }
@@ -2594,6 +2722,107 @@
    }
}
private final class AppGridScrollInteractionState: ObservableObject {
    @Published var isFrozen = false
    private var unfreezeWorkItem: DispatchWorkItem?
    func noteScroll() {
        if !isFrozen {
            isFrozen = true
        }
        unfreezeWorkItem?.cancel()
        let workItem = DispatchWorkItem { [weak self] in
            self?.isFrozen = false
        }
        unfreezeWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: workItem)
    }
    func reset() {
        unfreezeWorkItem?.cancel()
        unfreezeWorkItem = nil
        isFrozen = false
    }
}
private struct AppGridScrollActivityObserver: NSViewRepresentable {
    let onScroll: () -> Void
    func makeCoordinator() -> Coordinator {
        Coordinator(onScroll: onScroll)
    }
    func makeNSView(context: Context) -> ScrollActivityNSView {
        let view = ScrollActivityNSView()
        view.coordinator = context.coordinator
        return view
    }
    func updateNSView(_ view: ScrollActivityNSView, context: Context) {
        context.coordinator.onScroll = onScroll
        view.coordinator = context.coordinator
        view.installObserverIfPossible()
    }
    final class Coordinator {
        var onScroll: () -> Void
        private weak var observedClipView: NSClipView?
        private var observer: NSObjectProtocol?
        init(onScroll: @escaping () -> Void) {
            self.onScroll = onScroll
        }
        func install(from view: NSView) {
            guard let clipView = view.enclosingScrollView?.contentView else { return }
            guard observedClipView !== clipView else { return }
            removeObserver()
            observedClipView = clipView
            clipView.postsBoundsChangedNotifications = true
            observer = NotificationCenter.default.addObserver(
                forName: NSView.boundsDidChangeNotification,
                object: clipView,
                queue: .main
            ) { [weak self] _ in
                self?.onScroll()
            }
        }
        private func removeObserver() {
            if let observer {
                NotificationCenter.default.removeObserver(observer)
            }
            observer = nil
            observedClipView = nil
        }
        deinit {
            removeObserver()
        }
    }
    final class ScrollActivityNSView: NSView {
        weak var coordinator: Coordinator?
        override func viewDidMoveToWindow() {
            super.viewDidMoveToWindow()
            installObserverIfPossible()
        }
        override func viewDidMoveToSuperview() {
            super.viewDidMoveToSuperview()
            installObserverIfPossible()
        }
        func installObserverIfPossible() {
            DispatchQueue.main.async { [weak self] in
                guard let self else { return }
                coordinator?.install(from: self)
            }
        }
    }
}
// MARK: - Tag Label
private struct TagReorderFramePreferenceKey: PreferenceKey {
Apptag/QuickSearch.swift
@@ -202,32 +202,7 @@
// MARK: - Search Documents
struct QuickSearchDocument: Identifiable {
    var id: URL { app.id }
    let app: AppInfo
    let localizedNames: [String]
    let internalBundleNames: [String]
    let tagNames: [String]
    let note: String
    let bundleIdentifier: String
    let lastOpenedAt: Date?
    let openCount: Int
}
struct QuickSearchResult: Identifiable {
    var id: URL { document.id }
    let document: QuickSearchDocument
    let finalScore: Double
    let textScore: Double
    let bestFieldRank: Int
    let matchedTagName: String?
    let noteSnippet: String?
    var app: AppInfo { document.app }
}
enum QuickSearchEngine {
    private enum FieldKind: Int {
private enum QuickSearchFieldKind: Int {
        case name = 0
        case tag = 1
        case note = 2
@@ -245,7 +220,7 @@
        }
    }
    private enum MatchKind {
private enum QuickSearchMatchKind {
        case exact
        case prefix
        case substring
@@ -263,37 +238,76 @@
        }
    }
    private struct Field {
        let kind: FieldKind
private struct QuickSearchIndexedField {
    let kind: QuickSearchFieldKind
        let text: String
        let normalized: String
        let acronym: String
        let pinyinCandidates: [String]
    }
    private struct MatchOptions {
private struct QuickSearchMatchOptions {
        let allowSubstring: Bool
        let allowFuzzySubsequence: Bool
    }
    private struct TokenMatch {
private struct QuickSearchTokenMatch {
        let score: Double
        let fieldRank: Int
        let fieldKind: FieldKind
    let fieldKind: QuickSearchFieldKind
        let originalText: String
    }
struct QuickSearchDocument: Identifiable {
    var id: URL { app.id }
    let app: AppInfo
    let localizedNames: [String]
    let internalBundleNames: [String]
    let tagNames: [String]
    let note: String
    let bundleIdentifier: String
    let lastOpenedAt: Date?
    let openCount: Int
    fileprivate let searchableFields: [QuickSearchIndexedField]
}
struct QuickSearchResult: Identifiable {
    var id: URL { document.id }
    let document: QuickSearchDocument
    let finalScore: Double
    let textScore: Double
    let bestFieldRank: Int
    let matchedTagName: String?
    let noteSnippet: String?
    var app: AppInfo { document.app }
}
enum QuickSearchEngine {
    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 = localizedNames(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: 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 +334,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,10 +367,17 @@
        )
    }
    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(
            QuickSearchIndexedField(
                kind: .name,
                text: $0,
                normalized: normalizeField($0),
@@ -364,8 +385,8 @@
                pinyinCandidates: pinyinCandidates(for: $0)
            )
        }
        let tagFields = document.tagNames.map {
            Field(
        let tagFields = tagNames.map {
            QuickSearchIndexedField(
                kind: .tag,
                text: $0,
                normalized: normalizeField($0),
@@ -373,26 +394,26 @@
                pinyinCandidates: pinyinCandidates(for: $0)
            )
        }
        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)
            )
        ]
        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: []
            )
        ]
        let internalBundleNameFields = document.internalBundleNames.map {
            Field(
        let internalBundleNameFields = internalBundleNames.map {
            QuickSearchIndexedField(
                kind: .internalBundleName,
                text: $0,
                normalized: normalizeField($0),
@@ -403,11 +424,11 @@
        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 +436,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,
@@ -444,24 +465,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)
@@ -482,7 +503,7 @@
        token: String,
        normalized: String,
        allowSubstring: Bool = false
    ) -> (MatchKind, Int)? {
    ) -> (QuickSearchMatchKind, Int)? {
        guard !normalized.isEmpty else { return nil }
        if normalized == token {
            return (.exact, 0)
CHANGELOG.md
@@ -2,9 +2,13 @@
## [7.6.0] — 2026-05-20
- 优化主界面滚动性能:网格容器改为懒加载,并在滚动期间临时冻结 App hover 放大、用途气泡和容器 hover 高亮
- 将主界面的分组结果与网格容器行布局改为缓存状态,避免鼠标 hover、搜索输入等轻量状态变化反复触发分组和布局计算
- 优化 Quick Search:打开浮层时复用已加载 App 数据,不再每次打开都触发全量刷新;搜索字段在索引阶段预计算,输入时避免重复规格化、首字母和拼音候选生成
- 减少 App 图标重绘:只有图标对象或尺寸变化时才触发 `NSView` 重绘
- 将当前满意版标记为 `7.6.0`,作为后续性能优化前的稳定归档基线
- 保留 7.5.1 的 Quick Search、快捷键状态、Smart Start 目录和文档沉淀成果,不引入新的功能变更
- 版本号更新为 `7.6.0`,Build 更新为 `20260520.1250`
- 版本号更新为 `7.6.0`,基线 Build 为 `20260520.1250`;本次性能优化验证 Build 为 `20260520.1400`
## [7.5.1] — 2026-05-19