From 3fe86413f666f0a1ce8b8118dcfb303d0f1b5420 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 29 May 2026 11:47:16 +0800
Subject: [PATCH] Harden app grid drop and keyboard routing

---
 Apptag/OverlayWindowController.swift |   12 +++
 Apptag/ApptagApp.swift               |   50 +++++++++---
 TODO.md                              |   10 ++
 Apptag/AppGridCollectionView.swift   |   12 ++
 Scripts/window_logic_qa.sh           |   65 ++++++++++++++++
 Apptag/ContentView.swift             |   20 +++++
 Apptag/EditModeViews.swift           |   29 +++++++
 Apptag/AppDefaults.swift             |    1 
 8 files changed, 184 insertions(+), 15 deletions(-)

diff --git a/Apptag/AppDefaults.swift b/Apptag/AppDefaults.swift
index f0472ac..06eb7bc 100644
--- a/Apptag/AppDefaults.swift
+++ b/Apptag/AppDefaults.swift
@@ -26,6 +26,7 @@
             "showUncommonAppBubbles": showUncommonAppBubbles,
             "useAppKitTagNavigation": useAppKitTagNavigation,
             "skipTagRemovalDropConfirm": false,
+            "skipUncategorizedDropConfirm": false,
             LauncherHotkeyRegistrationStore.mainStateKey: LauncherHotkeyRegistrationState.active.rawValue,
             LauncherHotkeyRegistrationStore.quickSearchStateKey: LauncherHotkeyRegistrationState.active.rawValue
         ])
diff --git a/Apptag/AppGridCollectionView.swift b/Apptag/AppGridCollectionView.swift
index 013e915..a37c414 100644
--- a/Apptag/AppGridCollectionView.swift
+++ b/Apptag/AppGridCollectionView.swift
@@ -252,9 +252,19 @@
     }
 }
 
+private final class AppGridScrollView: NSScrollView {
+    override var acceptsFirstResponder: Bool { true }
+
+    override func scrollWheel(with event: NSEvent) {
+        window?.makeKeyAndOrderFront(nil)
+        window?.orderFrontRegardless()
+        super.scrollWheel(with: event)
+    }
+}
+
 final class AppGridCollectionHostView: NSView, AppEmptyDropReceivingView {
     private let emptyDropTargetID = UUID()
-    private let scrollView = NSScrollView()
+    private let scrollView = AppGridScrollView()
     private let collectionView = NSCollectionView()
     private let gridLayout = AppGridContainerCollectionLayout()
     private weak var coordinator: AppGridCollectionView.Coordinator?
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index 7f0fabc..ccc3c80 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -92,6 +92,9 @@
                     }
                 )
             },
+            handleOverlayKeyEvent: { [weak self] event in
+                self?.handleOverlayKeyEvent(event) ?? false
+            },
             installOverlayKeyMonitor: { [weak self] in
                 self?.installOverlayKeyMonitor()
             },
@@ -851,26 +854,45 @@
         guard overlayKeyMonitor == nil else { return }
         overlayKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
             guard let self else { return event }
-            guard self.shouldHandleOverlayKeyEvent(event) else { return event }
-            if event.keyCode == 53 { // Escape
-                if self.isQuickSearchOpen {
-                    self.isQuickSearchOpen = false
-                    self.removeQuickSearchExternalMouseMonitor()
-                    self.updateOverlayLevelForTextInput()
-                    NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil)
-                    return nil
-                }
-                self.hideOverlay(force: true)
-                return nil
-            }
-            if self.shouldOpenQuickSearch(for: event) {
-                self.requestQuickSearch(source: QuickSearchOpenSource.mainOverlay)
+            if self.handleOverlayKeyEvent(event) {
                 return nil
             }
             return event
         }
     }
 
+    @discardableResult
+    private func handleOverlayKeyEvent(_ event: NSEvent) -> Bool {
+        guard event.type == .keyDown,
+              shouldHandleOverlayKeyEvent(event)
+        else { return false }
+
+        if event.keyCode == UInt16(kVK_Escape) {
+            return handleOverlayEscapeKey()
+        }
+        if shouldOpenQuickSearch(for: event) {
+            requestQuickSearch(source: QuickSearchOpenSource.mainOverlay)
+            return true
+        }
+        return false
+    }
+
+    private func handleOverlayEscapeKey() -> Bool {
+        if isQuickSearchOpen {
+            isQuickSearchOpen = false
+            removeQuickSearchExternalMouseMonitor()
+            updateOverlayLevelForTextInput()
+            NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil)
+            return true
+        }
+        guard !isSettingsVisible,
+              !isEditingAppNote,
+              !isModalInteractionActive
+        else { return false }
+        hideOverlay(force: true)
+        return true
+    }
+
     private func requestQuickSearch(source: String) {
         isQuickSearchOpen = true
         promoteOverlayToForegroundInput()
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index e5389cd..275e8d3 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -313,6 +313,7 @@
     var editingBubble: AppBubbleContext? = nil
     var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
     var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+    var uncategorizedDropSuppressFuturePrompt = false
     var tagRemovalDropSuppressFuturePrompt = false
     var appDragResetToken = 0
     var bubbleDraftNote = ""
@@ -409,6 +410,7 @@
     @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
     @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
     @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
+    @AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false
 
     private let editSidebarWidth: CGFloat = 188
     private let editSidebarHorizontalInset: CGFloat = 12
@@ -1420,6 +1422,8 @@
                     UncategorizedDropConfirmBubble(
                         title: tr("drop.uncategorizedConfirmTitle"),
                         message: uncategorizedConfirmMessage(for: pendingDrop),
+                        doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"),
+                        doNotRemind: $appGridInteraction.uncategorizedDropSuppressFuturePrompt,
                         cancelTitle: tr("tag.cancel"),
                         confirmTitle: tr("edit.confirm"),
                         onCancel: dismissUncategorizedDropConfirm,
@@ -2046,7 +2050,13 @@
         guard !removableTags.isEmpty else { return }
         let assignedTags = assignedRegularDisplayTags(for: removableTags)
 
+        if skipUncategorizedDropConfirm {
+            moveDroppedAppToUncategorized(path: app.path.path, tags: removableTags)
+            return
+        }
+
         clearAppBubbleState()
+        appGridInteraction.uncategorizedDropSuppressFuturePrompt = false
         withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
             appGridInteraction.pendingUncategorizedDrop = PendingUncategorizedDrop(
                 app: app,
@@ -2100,6 +2110,7 @@
     }
 
     private func dismissUncategorizedDropConfirm() {
+        appGridInteraction.uncategorizedDropSuppressFuturePrompt = false
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
         withAnimation(.easeOut(duration: 0.18)) {
             appGridInteraction.pendingUncategorizedDrop = nil
@@ -2110,10 +2121,19 @@
         guard let pendingDrop = appGridInteraction.pendingUncategorizedDrop else { return }
         let path = pendingDrop.app.path.path
         let tags = pendingDrop.removableTags
+        let shouldSuppressFuturePrompt = appGridInteraction.uncategorizedDropSuppressFuturePrompt
+        if shouldSuppressFuturePrompt {
+            skipUncategorizedDropConfirm = true
+        }
+        appGridInteraction.uncategorizedDropSuppressFuturePrompt = false
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
         withAnimation(.easeOut(duration: 0.16)) {
             appGridInteraction.pendingUncategorizedDrop = nil
         }
+        moveDroppedAppToUncategorized(path: path, tags: tags)
+    }
+
+    private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
         guard !tags.isEmpty else { return }
         TagEditor.removeTags(tags, from: [path])
         showDropRefresh()
diff --git a/Apptag/EditModeViews.swift b/Apptag/EditModeViews.swift
index fd4f68e..1053557 100644
--- a/Apptag/EditModeViews.swift
+++ b/Apptag/EditModeViews.swift
@@ -314,6 +314,8 @@
 struct UncategorizedDropConfirmBubble: View {
     let title: String
     let message: String
+    let doNotRemindTitle: String
+    @Binding var doNotRemind: Bool
     let cancelTitle: String
     let confirmTitle: String
     let onCancel: () -> Void
@@ -348,6 +350,33 @@
                 .fixedSize(horizontal: false, vertical: true)
                 .frame(maxWidth: .infinity, alignment: .leading)
 
+            Button {
+                doNotRemind.toggle()
+            } label: {
+                HStack(spacing: 10) {
+                    ZStack {
+                        RoundedRectangle(cornerRadius: 4, style: .continuous)
+                            .fill(doNotRemind ? Color.accentColor : Color.white.opacity(0.10))
+                        RoundedRectangle(cornerRadius: 4, style: .continuous)
+                            .stroke(Color.white.opacity(0.92), lineWidth: 1.6)
+                        if doNotRemind {
+                            Image(systemName: "checkmark")
+                                .font(.system(size: 12, weight: .bold))
+                                .foregroundStyle(.white)
+                        }
+                    }
+                    .frame(width: 18, height: 18)
+
+                    Text(doNotRemindTitle)
+                        .font(.system(size: 13, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.82))
+                        .lineLimit(2)
+                        .multilineTextAlignment(.leading)
+                }
+                .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+
             HStack(spacing: 12) {
                 Spacer(minLength: 0)
 
diff --git a/Apptag/OverlayWindowController.swift b/Apptag/OverlayWindowController.swift
index 03f74f2..99ac779 100644
--- a/Apptag/OverlayWindowController.swift
+++ b/Apptag/OverlayWindowController.swift
@@ -2,8 +2,18 @@
 import SwiftUI
 
 final class OverlayPanel: NSPanel {
+    var handleOverlayKeyEvent: ((NSEvent) -> Bool)?
+
     override var canBecomeKey: Bool { true }
     override var canBecomeMain: Bool { true }
+
+    override func sendEvent(_ event: NSEvent) {
+        if event.type == .keyDown,
+           handleOverlayKeyEvent?(event) == true {
+            return
+        }
+        super.sendEvent(event)
+    }
 
     override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect {
         frameRect
@@ -19,6 +29,7 @@
         let currentOverlayLevel: () -> NSWindow.Level
         let overlayLevel: (_ initialQuickSearchSource: String?) -> NSWindow.Level
         let makeContentView: (_ initialQuickSearchSource: String?) -> NSView
+        let handleOverlayKeyEvent: (NSEvent) -> Bool
         let installOverlayKeyMonitor: () -> Void
         let removeOverlayKeyMonitor: () -> Void
         let removeQuickSearchMouseMonitor: () -> Void
@@ -215,6 +226,7 @@
         panel.titlebarAppearsTransparent = true
         panel.titleVisibility = .hidden
         panel.isReleasedWhenClosed = false
+        panel.handleOverlayKeyEvent = dependencies.handleOverlayKeyEvent
         panel.contentView = dependencies.makeContentView(initialQuickSearchSource)
         return panel
     }
diff --git a/Scripts/window_logic_qa.sh b/Scripts/window_logic_qa.sh
index f7dc843..3d90bec 100755
--- a/Scripts/window_logic_qa.sh
+++ b/Scripts/window_logic_qa.sh
@@ -435,6 +435,16 @@
     guard dockWindows.isEmpty else { fail("Dock should be hidden while overlay is visible; found \(dockWindows.count) Dock windows") }
     print("PASS overlay: tagLayer=\(tag[0].layer) menubarLayer=24 dockWindows=0")
 
+case "quick-search":
+    guard tag.count == 2 else { fail("quick search expected overlay plus panel, got \(tag.count)") }
+    assertTagLayer(tag)
+    let overlays = tag.filter(isOverlayWindow)
+    let quickSearch = tag.filter(isQuickSearchWindow)
+    guard overlays.count == 1, quickSearch.count == 1 else {
+        fail("quick search stack wrong: names=\(tag.map(\.name)) bounds=\(tag.map(\.bounds))")
+    }
+    print("PASS quick search stack: tagLayers=\(tag.map(\.layer))")
+
 case "settings":
     guard tag.count == 2 else { fail("settings expected 2 TagLauncher windows, got \(tag.count)") }
     assertTagLayer(tag)
@@ -685,6 +695,23 @@
     let overlayX = dimension(overlayBounds, "X")
     let overlayY = dimension(overlayBounds, "Y")
     print("\(Int(round(overlayX + 120))) \(Int(round(overlayY + 160)))")
+case "overlay-center":
+    guard let overlay = tag.max(by: { lhs, rhs in
+        let lhsBounds = lhs[kCGWindowBounds as String] as? NSDictionary ?? [:]
+        let rhsBounds = rhs[kCGWindowBounds as String] as? NSDictionary ?? [:]
+        let lhsArea = dimension(lhsBounds, "Width") * dimension(lhsBounds, "Height")
+        let rhsArea = dimension(rhsBounds, "Width") * dimension(rhsBounds, "Height")
+        return lhsArea < rhsArea
+    }), let overlayBounds = overlay[kCGWindowBounds as String] as? NSDictionary else {
+        let screen = NSScreen.screens.first?.frame ?? CGRect(x: 0, y: 0, width: 1200, height: 800)
+        print("\(Int(round(screen.midX))) \(Int(round(screen.midY)))")
+        exit(0)
+    }
+    let overlayX = dimension(overlayBounds, "X")
+    let overlayY = dimension(overlayBounds, "Y")
+    let overlayWidth = dimension(overlayBounds, "Width")
+    let overlayHeight = dimension(overlayBounds, "Height")
+    print("\(Int(round(overlayX + overlayWidth / 2))) \(Int(round(overlayY + overlayHeight / 2)))")
 case "fullscreen-target-center":
     guard let target = raw.first(where: { ($0[kCGWindowName as String] as? String) == "TagLauncherFullscreenQATargetFullscreen" }),
           let bounds = target[kCGWindowBounds as String] as? NSDictionary else {
@@ -944,6 +971,28 @@
   click_xy "$x" "$y"
 }
 
+page_scroll_appgrid() {
+  local coords
+  coords="$(swift "$coords_swift" overlay-center)"
+  read -r x y <<<"$coords"
+  move_xy "$x" "$y"
+  sleep 0.15
+  swift - <<'SWIFT'
+import CoreGraphics
+if let event = CGEvent(
+    scrollWheelEvent2Source: nil,
+    units: .pixel,
+    wheelCount: 1,
+    wheel1: -900,
+    wheel2: 0,
+    wheel3: 0
+) {
+    event.post(tap: .cghidEventTap)
+}
+SWIFT
+  sleep 0.25
+}
+
 kill_fullscreen_qa_target() {
   if [[ -n "${FULLSCREEN_QA_PID:-}" ]]; then
     kill "$FULLSCREEN_QA_PID" >/dev/null 2>&1 || true
@@ -1092,6 +1141,22 @@
 sleep 0.4
 swift_assert no-overlay
 
+log "==> QA 4/7: appgrid scroll keeps Space and Esc keyboard routing"
+show_overlay
+for _ in {1..5}; do
+  page_scroll_appgrid
+  send_keycode 49
+  sleep 0.35
+  swift_assert quick-search
+  send_keycode 53
+  sleep 0.25
+  swift_assert overlay
+done
+page_scroll_appgrid
+send_keycode 53
+sleep 0.35
+swift_assert no-overlay
+
 log "==> QA 5/7: clicking outside quick search closes search, not appgrid"
 show_overlay
 send_keycode 49
diff --git a/TODO.md b/TODO.md
index 59f85d5..9f8bcd5 100644
--- a/TODO.md
+++ b/TODO.md
@@ -13,8 +13,18 @@
 
 ## Todo
 
+- [2026-05-29] 第二批:AppGrid 滚动流畅性优化。
+  - 目标: 降低滚动期间 SwiftUI 状态写入和 visible item 刷新频率,改善偶发卡顿/抖动。
+  - 验收: 滚动手感稳定;hover 气泡不在滚动中闪现;不影响标签 hover 定位和拖拽。
+
 ## Done
 
+- [2026-05-29] 第一批:未分类拖拽确认记忆 + 滚动后键盘兜底。
+  - 提交: 本次提交
+  - 结果: “拖到未分类”弹窗新增独立“不再提醒”checkbox 和 `skipUncategorizedDropConfirm` 持久化;overlay 键盘处理增加 AppKit `sendEvent` 兜底;AppGrid 滚轮进入时重新声明 key/front,避免滚动后 Space/Esc 偶发丢失。
+  - 范围: 不改拖到空隙移除单标签语义;不改全屏/Split View 窗口层级;不改 Quick Search 独立面板结构。
+  - 验证: `bash build.sh`、`codesign --verify --deep --strict`、`Scripts/window_logic_qa.sh` 全部通过;新增滚动后 Space/Esc 连续循环 QA;屏幕 smoke 验证滚动后 Space 打开 Quick Search、Esc 关闭 Quick Search、再次 Esc 关闭 AppGrid。
+
 - [2026-05-29] 设置页和 Smart Start 模块化。
   - 提交: 本次提交
   - 结果: `SmartStartNoticeOverlay` 已从 `ContentView` 拆出;设置页“应用系统智能化初始分类”的扫描、Smart Start 应用和 snapshot 组装改由 `AppLibraryController` 统一处理;窗口 QA 脚本同步适配 `OverlayWindowController` 的屏幕选择静态检查。

--
Gitblit v1.9.3