Ariver
2026-05-29 3fe86413f666f0a1ce8b8118dcfb303d0f1b5420
Harden app grid drop and keyboard routing
8 files modified
199 ■■■■■ changed files
Apptag/AppDefaults.swift 1 ●●●● patch | view | raw | blame | history
Apptag/AppGridCollectionView.swift 12 ●●●●● patch | view | raw | blame | history
Apptag/ApptagApp.swift 50 ●●●● patch | view | raw | blame | history
Apptag/ContentView.swift 20 ●●●●● patch | view | raw | blame | history
Apptag/EditModeViews.swift 29 ●●●●● patch | view | raw | blame | history
Apptag/OverlayWindowController.swift 12 ●●●●● patch | view | raw | blame | history
Scripts/window_logic_qa.sh 65 ●●●●● patch | view | raw | blame | history
TODO.md 10 ●●●●● patch | view | raw | blame | history
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
        ])
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?
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()
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()
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)
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
    }
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
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` 的屏幕选择静态检查。