Harden app grid drop and keyboard routing
| | |
| | | "showUncommonAppBubbles": showUncommonAppBubbles, |
| | | "useAppKitTagNavigation": useAppKitTagNavigation, |
| | | "skipTagRemovalDropConfirm": false, |
| | | "skipUncategorizedDropConfirm": false, |
| | | LauncherHotkeyRegistrationStore.mainStateKey: LauncherHotkeyRegistrationState.active.rawValue, |
| | | LauncherHotkeyRegistrationStore.quickSearchStateKey: LauncherHotkeyRegistrationState.active.rawValue |
| | | ]) |
| | |
| | | } |
| | | } |
| | | |
| | | 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? |
| | |
| | | } |
| | | ) |
| | | }, |
| | | handleOverlayKeyEvent: { [weak self] event in |
| | | self?.handleOverlayKeyEvent(event) ?? false |
| | | }, |
| | | installOverlayKeyMonitor: { [weak self] in |
| | | self?.installOverlayKeyMonitor() |
| | | }, |
| | |
| | | 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() |
| | |
| | | var editingBubble: AppBubbleContext? = nil |
| | | var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil |
| | | var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil |
| | | var uncategorizedDropSuppressFuturePrompt = false |
| | | var tagRemovalDropSuppressFuturePrompt = false |
| | | var appDragResetToken = 0 |
| | | var bubbleDraftNote = "" |
| | |
| | | @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 |
| | |
| | | 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, |
| | |
| | | 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, |
| | |
| | | } |
| | | |
| | | private func dismissUncategorizedDropConfirm() { |
| | | appGridInteraction.uncategorizedDropSuppressFuturePrompt = false |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | appGridInteraction.pendingUncategorizedDrop = nil |
| | |
| | | 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() |
| | |
| | | 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 |
| | |
| | | .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) |
| | | |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | panel.titlebarAppearsTransparent = true |
| | | panel.titleVisibility = .hidden |
| | | panel.isReleasedWhenClosed = false |
| | | panel.handleOverlayKeyEvent = dependencies.handleOverlayKeyEvent |
| | | panel.contentView = dependencies.makeContentView(initialQuickSearchSource) |
| | | return panel |
| | | } |
| | |
| | | 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) |
| | |
| | | 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 { |
| | |
| | | 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 |
| | |
| | | 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 |
| | | |
| | | - [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` 的屏幕选择静态检查。 |