From efbfc27e0c813c31b570d72fded40eb91d07f2db Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 21:01:19 +0800
Subject: [PATCH] Add AppKit tag navigation reordering
---
Apptag/ContentView.swift | 2345 ++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 2,000 insertions(+), 345 deletions(-)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 7411edc..da98d57 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -4,7 +4,13 @@
// MARK: - Notification for manual re-index
extension Notification.Name {
- static let apptagEditModeChanged = Notification.Name("ApptagEditModeChanged")
+ static let tagLauncherEditModeChanged = Notification.Name("TagLauncherEditModeChanged")
+ static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged")
+ static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange")
+ static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested")
+ static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow")
+ static let tagLauncherOverlayDidHide = Notification.Name("TagLauncherOverlayDidHide")
+ static let tagLauncherModalInteractionChanged = Notification.Name("TagLauncherModalInteractionChanged")
}
// MARK: - Edit Phase
@@ -13,6 +19,11 @@
case none
case editingTags
case editingApps
+}
+
+enum EditTagOperation {
+ case add
+ case remove
}
// MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
@@ -104,11 +115,91 @@
}
}
+// MARK: - Native Floating Icon Button
+
+final class FloatingIconButtonView: NSView {
+ private let imageView = NSImageView()
+ var action: (() -> Void)?
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ addSubview(imageView)
+ imageView.imageScaling = .scaleProportionallyDown
+ imageView.contentTintColor = .secondaryLabelColor
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ override func layout() {
+ super.layout()
+ let size: CGFloat = 16
+ imageView.frame = NSRect(
+ x: (bounds.width - size) / 2,
+ y: (bounds.height - size) / 2,
+ width: size,
+ height: size
+ )
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ action?()
+ }
+
+ func setSystemImage(_ systemImage: String) {
+ let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium)
+ imageView.image = NSImage(
+ systemSymbolName: systemImage,
+ accessibilityDescription: nil
+ )?.withSymbolConfiguration(config)
+ }
+}
+
+private struct NativeFloatingIconButton: NSViewRepresentable {
+ let systemImage: String
+ let action: () -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(action: action)
+ }
+
+ func makeNSView(context: Context) -> FloatingIconButtonView {
+ let view = FloatingIconButtonView(frame: NSRect(x: 0, y: 0, width: 36, height: 36))
+ configure(view, context: context)
+ return view
+ }
+
+ func updateNSView(_ view: FloatingIconButtonView, context: Context) {
+ context.coordinator.action = action
+ configure(view, context: context)
+ }
+
+ private func configure(_ view: FloatingIconButtonView, context: Context) {
+ view.setSystemImage(systemImage)
+ view.action = {
+ context.coordinator.action()
+ }
+ }
+
+ final class Coordinator {
+ var action: () -> Void
+
+ init(action: @escaping () -> Void) {
+ self.action = action
+ }
+ }
+}
+
// MARK: - Tag Pill (for top navigation bar)
struct TagPill: View {
let name: String
let colorIndex: Int
+ var dragModeActive: Bool = false
+ var isDragging: Bool = false
let action: () -> Void
private var bgColor: Color {
@@ -123,16 +214,21 @@
}
var body: some View {
- Button(action: action) {
- Text(name)
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(textColor)
- .padding(.horizontal, 12)
- .padding(.vertical, 6)
- .background(RoundedRectangle(cornerRadius: 7).fill(bgColor))
- .shadow(color: .black.opacity(0.2), radius: 3, y: 1)
- }
- .buttonStyle(.plain)
+ Text(name)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(textColor)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(RoundedRectangle(cornerRadius: 7).fill(bgColor))
+ .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1)
+ .scaleEffect(isDragging ? 1.05 : 1.0)
+ .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
+ .animation(.easeOut(duration: 0.08), value: isDragging)
+ .animation(.easeOut(duration: 0.08), value: dragModeActive)
+ .contentShape(RoundedRectangle(cornerRadius: 7))
+ .onTapGesture {
+ action()
+ }
}
}
@@ -141,6 +237,8 @@
struct SideTagPill: View {
let name: String
let colorIndex: Int
+ var dragModeActive: Bool = false
+ var isDragging: Bool = false
let action: () -> Void
private var bgColor: Color {
@@ -151,16 +249,21 @@
}
var body: some View {
- Button(action: action) {
- Text(name)
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(textColor)
- .padding(.horizontal, 10).padding(.vertical, 5)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(RoundedRectangle(cornerRadius: 6).fill(bgColor))
- .shadow(color: .black.opacity(0.2), radius: 3, y: 1)
- }
- .buttonStyle(.plain)
+ Text(name)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(textColor)
+ .padding(.horizontal, 10).padding(.vertical, 5)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: 6).fill(bgColor))
+ .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1)
+ .scaleEffect(isDragging ? 1.03 : 1.0)
+ .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
+ .animation(.easeOut(duration: 0.08), value: isDragging)
+ .animation(.easeOut(duration: 0.08), value: dragModeActive)
+ .contentShape(RoundedRectangle(cornerRadius: 6))
+ .onTapGesture {
+ action()
+ }
}
}
@@ -189,85 +292,349 @@
// MARK: - Full-Screen Overlay
+private struct AppBubbleContext {
+ let app: AppInfo
+ let frame: CGRect
+}
+
+private struct AppBubbleMetrics {
+ let centerX: CGFloat
+ let centerY: CGFloat
+ let arrowX: CGFloat
+ let arrowOffset: CGFloat
+}
+
+private struct AppGridInteractionState {
+ var appDragModeActive = false
+ var dropWarningToast: String? = nil
+ var dropRefreshVisible = false
+ var dropRefreshStartedAt: Date? = nil
+ var hoveredBubble: AppBubbleContext? = nil
+ var editingBubble: AppBubbleContext? = nil
+ var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
+ var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+ var tagRemovalDropSuppressFuturePrompt = false
+ var appDragResetToken = 0
+ var bubbleDraftNote = ""
+ var tagNavigationHoveredGroupName: String? = nil
+ var tagNavigationLastHoverScrollID: String? = nil
+ var tagNavigationLastHoverScrollAt: Date? = nil
+}
+
+private struct EditActionFeedback: Identifiable {
+ let id = UUID()
+ let title: String
+ let message: String
+}
+
+private struct PendingUncategorizedDrop: Identifiable {
+ let id = UUID()
+ let app: AppInfo
+ let assignedTags: [String]
+ let removableTags: [String]
+}
+
+private struct PendingTagRemovalDrop: Identifiable {
+ let id = UUID()
+ let app: AppInfo
+ let tagName: String
+}
+
+private enum SmartStartNoticeMode {
+ case autoApplied
+ case suggestionOnly
+ case manuallyApplied
+}
+
+private struct SmartStartNotice: Identifiable {
+ let id = UUID()
+ let mode: SmartStartNoticeMode
+ let title: String
+ let message: String
+ let summary: SmartStartSummary
+}
+
struct ContentView: View {
let hideOverlay: () -> Void
+ private let initialQuickSearchSource: String?
+ @Environment(\.colorScheme) private var colorScheme
@State private var allApps: [AppInfo] = []
+ @State private var displayGroups: [TagGroup] = []
@State private var tagColors: [String: Int] = [:]
- @State private var scrollProxy: ScrollViewProxy? = nil
+ @State private var groupLayoutVersion = 0
+ @State private var appGridScrollTargetID: String? = nil
+ @State private var appGridScrollRequestToken = 0
// Edit mode
@State private var editPhase: EditPhase = .none
@State private var selectedAppPaths: Set<URL> = []
@State private var selectedTagNames: Set<String> = []
- @State private var successToast: String? = nil
+ @State private var removableTagNames: Set<String> = []
+ @State private var editTagOperation: EditTagOperation = .add
+ @State private var editActionFeedback: EditActionFeedback? = nil
@State private var draggedTagNames: [String] = [] // live drag order
@State private var dragItem: String? = nil // currently dragged tag
- @State private var hoveredContainer: String? = nil // colored container lift
+ @State private var tagReorderFrames: [String: CGRect] = [:]
+ @State private var tagNavDragModeActive = false
+ @State private var tagNavDragItem: String? = nil
+ @State private var tagNavReorderFrames: [String: CGRect] = [:]
+ @State private var tagNavReorderDidMove = false
+ // Fixed interaction for "Colorless Container": hover fills persistently; click clears.
+ @State private var filledColorlessContainer: String? = nil
+ @State private var appGridInteraction = AppGridInteractionState()
+ @State private var smartStartNotice: SmartStartNotice? = nil
+ @State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil
+ @State private var refreshInProgress = false
+ @State private var refreshAgainAfterCurrent = false
+ @State private var refreshAgainForceLayout = false
+ @FocusState private var bubbleNoteFocused: Bool
+
+ // Quick Search
+ @State private var quickSearchVisible = false
+ @State private var quickSearchQuery = ""
+ @State private var quickSearchDocuments: [QuickSearchDocument] = []
+ @State private var quickSearchResults: [QuickSearchResult] = []
+ @State private var quickSearchSelectedID: URL? = nil
+ @State private var quickSearchManualSelection = false
+ @State private var quickSearchFocusToken = 0
+ @State private var quickSearchSelectionScrollToken = 0
+ @State private var quickSearchCloseHidesOverlay = false
+ @State private var quickSearchErrorMessage: String? = nil
+ @State private var initialQuickSearchConsumed = false
+
+ init(hideOverlay: @escaping () -> Void, initialQuickSearchSource: String? = nil) {
+ self.hideOverlay = hideOverlay
+ self.initialQuickSearchSource = initialQuickSearchSource
+ let startsInQuickSearch = initialQuickSearchSource != nil
+ _quickSearchVisible = State(initialValue: startsInQuickSearch)
+ _quickSearchCloseHidesOverlay = State(initialValue: initialQuickSearchSource == QuickSearchOpenSource.globalHidden)
+ _quickSearchFocusToken = State(initialValue: startsInQuickSearch ? 1 : 0)
+ }
// Configurable defaults
@AppStorage("defaultGroupName") private var defaultGroupName = "Other"
- @AppStorage("tagFontSize") private var tagFontSize: Double = 18
- @AppStorage("iconSize") private var iconSize: Double = 56
- @AppStorage("tagPosition") private var tagPosition = "left"
+ @AppStorage("tagFontSize") private var tagFontSize: Double = AppDefaults.tagFontSize
+ @AppStorage("iconSize") private var iconSize: Double = AppDefaults.iconSize
+ @AppStorage("tagPosition") private var tagPosition = AppDefaults.tagPosition
@State private var notchHeight: CGFloat = 0
- @AppStorage("displayMode") private var displayMode = "flat"
- @AppStorage("hideAppNames") private var hideAppNames = false
+ @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
+ @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
+ @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+ @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
+ @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
+
+ private let editSidebarWidth: CGFloat = 188
+ private let editSidebarHorizontalInset: CGFloat = 12
+ private let floatingControlsTrailingInset: CGFloat = 20
+ private let floatingControlsReservedWidth: CGFloat = 120
+ private var appBubbleDisabled: Bool {
+ appGridInteraction.appDragModeActive
+ || appGridInteraction.pendingUncategorizedDrop != nil
+ || appGridInteraction.pendingTagRemovalDrop != nil
+ }
+ private var appGridHighlightedGroupName: String? {
+ appGridInteraction.tagNavigationHoveredGroupName
+ ?? (isColorlessContainerMode ? filledColorlessContainer : nil)
+ }
+ private let rightSidebarFloatingClearance: CGFloat = 44
+ private let tagNavigationHoverScrollInterval: TimeInterval = 0.22
+
+ private var floatingButtonSurfaceColor: Color {
+ colorScheme == .dark
+ ? Color.white.opacity(0.10)
+ : Color.white.opacity(0.78)
+ }
private var isSideLayout: Bool {
tagPosition == "left" || tagPosition == "right"
}
+ private var floatingControlsTopInset: CGFloat {
+ notchHeight > 0 ? notchHeight + 10 : 20
+ }
+
+ private var isColorlessContainerMode: Bool {
+ displayMode == "container" || displayMode == "gridContainer"
+ }
+
+ private var shouldRenderAppGridBehindQuickSearch: Bool {
+ !quickSearchVisible || !quickSearchCloseHidesOverlay
+ }
+
var body: some View {
ZStack {
- VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
- .ignoresSafeArea()
- .allowsHitTesting(false)
-
- if notchHeight > 0 {
- VStack {
- Rectangle().fill(.black)
- .frame(height: notchHeight)
- .ignoresSafeArea(edges: .top)
- Spacer()
- }
- .allowsHitTesting(false)
+ if shouldRenderAppGridBehindQuickSearch {
+ VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
}
- switch editPhase {
- case .none:
- normalContent
- case .editingTags:
- editTagsView
- case .editingApps:
- editAppsView
+ if shouldRenderAppGridBehindQuickSearch {
+ if notchHeight > 0 {
+ VStack {
+ Rectangle().fill(.black)
+ .frame(height: notchHeight)
+ .ignoresSafeArea(edges: .top)
+ Spacer()
+ }
+ .allowsHitTesting(false)
+ }
+
+ switch editPhase {
+ case .none:
+ normalContent
+ case .editingTags:
+ editTagsView
+ case .editingApps:
+ editAppsView
+ }
+
+ uncommonAppBubbleOverlay
+ smartStartNoticeOverlay
+ editActionFeedbackOverlay
+ uncategorizedDropConfirmOverlay
+ tagRemovalDropConfirmOverlay
+ }
+
+ quickSearchOverlay
+
+ if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast {
+ Text(message)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(.primary)
+ .padding(.horizontal, 22)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(.ultraThickMaterial)
+ .shadow(color: .black.opacity(0.22), radius: 18, y: 10)
+ )
+ .transition(.scale(scale: 0.96).combined(with: .opacity))
+ .allowsHitTesting(false)
+ }
+
+ if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible {
+ Color.black.opacity(0.08)
+ .ignoresSafeArea()
+ .transition(.opacity)
+ .allowsHitTesting(false)
+
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.large)
+ .scaleEffect(1.15)
+ .padding(24)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(.ultraThickMaterial)
+ .shadow(color: .black.opacity(0.26), radius: 24, y: 12)
+ )
+ .transition(.scale(scale: 0.92).combined(with: .opacity))
+ .allowsHitTesting(false)
}
}
.onAppear {
- let mousePoint = NSEvent.mouseLocation
- let activeScreen = NSScreen.screens.first(where: {
- NSMouseInRect(mousePoint, $0.frame, false)
- }) ?? NSScreen.main
- notchHeight = activeScreen?.safeAreaInsets.top ?? 0
- refreshApps()
+ refreshNotchHeight()
+ refreshAppsIfNeeded()
+ if let initialQuickSearchSource, !initialQuickSearchConsumed {
+ initialQuickSearchConsumed = true
+ quickSearchCloseHidesOverlay = initialQuickSearchSource == QuickSearchOpenSource.globalHidden
+ if !quickSearchVisible {
+ quickSearchVisible = true
+ quickSearchFocusToken &+= 1
+ }
+ refreshQuickSearchResults()
+ NotificationCenter.default.post(
+ name: .tagLauncherQuickSearchVisibilityChanged,
+ object: nil,
+ userInfo: ["active": true]
+ )
+ }
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
+ resetTransientDragState()
+ refreshNotchHeight()
+ refreshAppsIfNeeded()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
+ resetTransientDragState()
+ closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested)) { notification in
+ let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
+ openQuickSearch(source: source)
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested)) { _ in
+ closeQuickSearch()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
+ guard allApps.isEmpty, !refreshInProgress else { return }
refreshApps()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
+ refreshApps(forceLayoutRefresh: true)
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
+ let appsSnapshot = allApps
+ DispatchQueue.global(qos: .userInitiated).async {
+ _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
+ DispatchQueue.main.async {
+ refreshApps(forceLayoutRefresh: true)
+ }
+ }
}
.onChange(of: editPhase) { _, newPhase in
let active = newPhase != .none
+ dismissAppBubble()
NotificationCenter.default.post(
- name: .apptagEditModeChanged,
+ name: .tagLauncherEditModeChanged,
object: nil,
userInfo: ["active": active]
)
}
+ .onChange(of: bubbleNoteFocused) { _, focused in
+ if appGridInteraction.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
+ refreshQuickSearchResults()
+ }
+ .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onDisappear {
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": false]
+ )
+ }
+ }
+
+ private func publishModalInteractionState() {
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil]
+ )
}
/// Set edit phase with synchronous notification BEFORE state change.
func setEditPhase(_ phase: EditPhase) {
if phase != .none {
- NotificationCenter.default.post(name: .apptagEditModeChanged, object: nil, userInfo: ["active": true])
+ NotificationCenter.default.post(name: .tagLauncherEditModeChanged, object: nil, userInfo: ["active": true])
NSApp.activate(ignoringOtherApps: true)
// Always sync tag list from database when entering edit mode
let store = TagDatabase.load()
@@ -276,10 +643,189 @@
}
editPhase = phase
if phase == .none {
+ TagDatabase.flushPendingCategorySchemeBackupBatch()
DispatchQueue.main.async {
- NotificationCenter.default.post(name: .apptagEditModeChanged, object: nil, userInfo: ["active": false])
+ NotificationCenter.default.post(name: .tagLauncherEditModeChanged, object: nil, userInfo: ["active": false])
}
}
+ }
+
+ // MARK: - Quick Search
+
+ private var quickSearchOverlay: some View {
+ GeometryReader { proxy in
+ if quickSearchVisible {
+ Color.black.opacity(0.001)
+ .frame(width: proxy.size.width, height: proxy.size.height)
+ .ignoresSafeArea()
+ .contentShape(Rectangle())
+ .onTapGesture {
+ closeQuickSearch()
+ }
+ .zIndex(899)
+
+ QuickSearchOverlayView(
+ query: $quickSearchQuery,
+ results: quickSearchResults,
+ selectedID: quickSearchSelectedID,
+ focusToken: quickSearchFocusToken,
+ selectionScrollToken: quickSearchSelectionScrollToken,
+ isLoading: quickSearchDocuments.isEmpty && refreshInProgress,
+ maxVisibleRows: quickSearchMaxVisibleRows(in: proxy.size),
+ errorMessage: quickSearchErrorMessage,
+ onCommand: handleQuickSearchCommand,
+ onHover: selectQuickSearchResult,
+ onLaunch: launchQuickSearchResult
+ )
+ .position(
+ x: proxy.size.width / 2,
+ y: quickSearchPanelCenterY(in: proxy.size)
+ )
+ .transition(.scale(scale: 0.98).combined(with: .opacity))
+ .zIndex(900)
+ }
+ }
+ .ignoresSafeArea()
+ .animation(.easeOut(duration: 0.12), value: quickSearchVisible)
+ }
+
+ private func quickSearchPanelCenterY(in size: CGSize) -> CGFloat {
+ let visibleRows = max(1, min(quickSearchResults.isEmpty ? 1 : quickSearchResults.count, quickSearchMaxVisibleRows(in: size)))
+ let estimatedPanelHeight = CGFloat(visibleRows) * 76 + 122
+ return quickSearchPanelTopY(in: size) + estimatedPanelHeight / 2
+ }
+
+ private func quickSearchPanelTopY(in size: CGSize) -> CGFloat {
+ max(notchHeight + 54, min(112, size.height * 0.12))
+ }
+
+ private func quickSearchMaxVisibleRows(in size: CGSize) -> Int {
+ let bottomClearance: CGFloat = 84
+ let chromeHeight: CGFloat = 122
+ let rowHeightWithSpacing: CGFloat = 76
+ let availableHeight = max(0, size.height - quickSearchPanelTopY(in: size) - bottomClearance - chromeHeight)
+ return max(1, min(8, Int(floor(availableHeight / rowHeightWithSpacing))))
+ }
+
+ private func openQuickSearch(source: String) {
+ guard canOpenQuickSearch else { return }
+ dismissAppBubble()
+ quickSearchCloseHidesOverlay = source == QuickSearchOpenSource.globalHidden
+ quickSearchVisible = true
+ quickSearchQuery = ""
+ quickSearchManualSelection = false
+ quickSearchErrorMessage = nil
+ quickSearchFocusToken &+= 1
+ refreshQuickSearchResults()
+ NotificationCenter.default.post(
+ name: .tagLauncherQuickSearchVisibilityChanged,
+ object: nil,
+ userInfo: ["active": true]
+ )
+ }
+
+ private var canOpenQuickSearch: Bool {
+ editPhase == .none
+ && appGridInteraction.pendingUncategorizedDrop == nil
+ && smartStartNotice == nil
+ && !appGridInteraction.dropRefreshVisible
+ && !quickSearchVisible
+ }
+
+ private func closeQuickSearch(notify: Bool = true, hideOverlayIfNeeded: Bool = true) {
+ let shouldHideOverlay = quickSearchVisible && quickSearchCloseHidesOverlay && hideOverlayIfNeeded
+ guard quickSearchVisible else { return }
+ quickSearchVisible = false
+ quickSearchQuery = ""
+ quickSearchResults = []
+ quickSearchSelectedID = nil
+ quickSearchManualSelection = false
+ quickSearchErrorMessage = nil
+ quickSearchCloseHidesOverlay = false
+ if notify {
+ NotificationCenter.default.post(
+ name: .tagLauncherQuickSearchVisibilityChanged,
+ object: nil,
+ userInfo: ["active": false]
+ )
+ }
+ if shouldHideOverlay {
+ hideOverlay()
+ }
+ }
+
+ private func refreshQuickSearchResults() {
+ let previousSelection = quickSearchSelectedID
+ quickSearchResults = QuickSearchEngine.search(quickSearchQuery, documents: quickSearchDocuments)
+
+ if quickSearchResults.isEmpty {
+ quickSearchSelectedID = nil
+ quickSearchManualSelection = false
+ return
+ }
+
+ if quickSearchManualSelection,
+ let previousSelection,
+ quickSearchResults.contains(where: { $0.id == previousSelection }) {
+ quickSearchSelectedID = previousSelection
+ } else {
+ quickSearchSelectedID = quickSearchResults.first?.id
+ quickSearchManualSelection = false
+ }
+ }
+
+ private func handleQuickSearchCommand(_ command: QuickSearchCommand) {
+ switch command {
+ case .moveUp:
+ moveQuickSearchSelection(by: -1)
+ case .moveDown:
+ moveQuickSearchSelection(by: 1)
+ case .submit:
+ guard let selected = selectedQuickSearchResult else { return }
+ launchQuickSearchResult(selected)
+ case .dismiss:
+ closeQuickSearch()
+ }
+ }
+
+ private var selectedQuickSearchResult: QuickSearchResult? {
+ guard let quickSearchSelectedID else { return nil }
+ return quickSearchResults.first { $0.id == quickSearchSelectedID }
+ }
+
+ private func moveQuickSearchSelection(by delta: Int) {
+ guard !quickSearchResults.isEmpty else { return }
+ let currentIndex = quickSearchSelectedID.flatMap { id in
+ quickSearchResults.firstIndex { $0.id == id }
+ } ?? 0
+ let nextIndex = min(max(currentIndex + delta, 0), quickSearchResults.count - 1)
+ quickSearchSelectedID = quickSearchResults[nextIndex].id
+ quickSearchManualSelection = true
+ quickSearchSelectionScrollToken &+= 1
+ }
+
+ private func selectQuickSearchResult(_ result: QuickSearchResult) {
+ guard quickSearchSelectedID != result.id || !quickSearchManualSelection else { return }
+ quickSearchSelectedID = result.id
+ quickSearchManualSelection = true
+ }
+
+ private func launchQuickSearchResult(_ result: QuickSearchResult) {
+ quickSearchErrorMessage = nil
+ launchApp(
+ result.app,
+ closeQuickSearchOnSuccess: true,
+ closeOverlayOnSuccess: true,
+ onFailure: {
+ quickSearchErrorMessage = tr("quickSearch.launchFailed")
+ quickSearchFocusToken &+= 1
+ NSAccessibility.post(
+ element: NSApp.mainWindow as Any,
+ notification: .announcementRequested,
+ userInfo: [.announcement: tr("quickSearch.launchFailed")]
+ )
+ }
+ )
}
// MARK: - Normal Content
@@ -292,20 +838,35 @@
topLayout
}
- Button {
- setEditPhase(.editingApps)
- } label: {
- Image(systemName: "pencil.line")
- .font(.system(size: 16, weight: .medium))
- .foregroundStyle(.secondary)
- .padding(10)
- .background(Circle().fill(.ultraThinMaterial))
- }
- .buttonStyle(.plain)
- .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20)
- .padding(.trailing, 20)
- .keyboardShortcut("e", modifiers: .control)
+ floatingActionButtons
+ .padding(.top, floatingControlsTopInset)
+ .padding(.trailing, floatingControlsTrailingInset)
+ .zIndex(2)
}
+ }
+
+ private var floatingActionButtons: some View {
+ HStack(spacing: 8) {
+ floatingOverlayButton(systemImage: "pencil.line") {
+ setEditPhase(.editingApps)
+ }
+ .keyboardShortcut("e", modifiers: .control)
+ .help(tr("edit.title"))
+
+ floatingOverlayButton(systemImage: "gearshape") {
+ NotificationCenter.default.post(name: .tagLauncherOpenPreferencesRequested, object: nil)
+ }
+ .help(tr("menu.preferences"))
+ }
+ }
+
+ private func floatingOverlayButton(
+ systemImage: String,
+ action: @escaping () -> Void
+ ) -> some View {
+ NativeFloatingIconButton(systemImage: systemImage, action: action)
+ .frame(width: 36, height: 36)
+ .background(Circle().fill(floatingButtonSurfaceColor))
}
// MARK: - Top / Side Layouts
@@ -313,7 +874,7 @@
private var topLayout: some View {
VStack(spacing: 0) {
Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28)
- if !tagLabels.isEmpty { tagBar.padding(.bottom, 8) }
+ if !tagLabels.isEmpty { topTagNavigation.padding(.bottom, 8) }
Divider().opacity(0.3)
appGridContent
}
@@ -324,43 +885,129 @@
Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28)
Divider().opacity(0.3)
HStack(spacing: 0) {
- if tagPosition == "left" { tagSidebar; sideDivider }
+ if tagPosition == "left" { leftTagSidebar; sideDivider }
appGridContent
- if tagPosition == "right" { sideDivider; tagSidebar }
+ if tagPosition == "right" { sideDivider; rightTagSidebar }
}
}
}
- private var tagBar: some View {
+ private var topTagNavigation: some View {
+ Group {
+ if useAppKitTagNavigation {
+ appKitTagNavigation(orientation: .horizontal)
+ .frame(height: 34)
+ } else {
+ swiftUITopTagBar
+ }
+ }
+ }
+
+ private var leftTagSidebar: some View {
+ Group {
+ if useAppKitTagNavigation {
+ appKitTagNavigation(orientation: .vertical)
+ } else {
+ swiftUITagSidebarList
+ }
+ }
+ .frame(width: 135)
+ }
+
+ private var rightTagSidebar: some View {
+ Group {
+ if useAppKitTagNavigation {
+ appKitTagNavigation(orientation: .vertical)
+ } else {
+ swiftUITagSidebarList
+ }
+ }
+ .padding(.top, rightSidebarFloatingClearance)
+ .frame(width: 135)
+ }
+
+ private func appKitTagNavigation(orientation: TagNavigationView.Orientation) -> some View {
+ let isHorizontal = orientation == .horizontal
+ return TagNavigationView(
+ items: tagLabels,
+ orientation: orientation,
+ contentInsets: isHorizontal
+ ? NSEdgeInsets(top: 3, left: 24, bottom: 3, right: tagPosition == "top" ? floatingControlsReservedWidth : 24)
+ : NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12),
+ dragModeActive: tagNavDragModeActive,
+ draggingItemID: tagNavDragItem,
+ onActivate: { tagID in
+ activateTagNavigation(tagID)
+ },
+ onHoverChange: { tagID, active in
+ handleTagNavigationHover(tagID, active: active)
+ },
+ canReorder: { tagID in
+ canReorderTag(tagID)
+ },
+ onReorderBegan: { tagID in
+ beginTagNavReorder(tagID)
+ },
+ onReorderMoved: { tagID, targetID in
+ reorderTagNavItem(fromName: tagID, to: targetID)
+ },
+ onReorderEnded: {
+ endTagNavReorder()
+ }
+ )
+ }
+
+ private var swiftUITopTagBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(tagLabels) { tag in
TagPill(name: tag.name, colorIndex: tag.colorIndex,
+ dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
+ isDragging: tagNavDragItem == tag.name,
action: {
- scrollTo(tag.id)
+ activateTagNavigation(tag.id)
})
+ .background(tagNavFrameReader(for: tag.name))
+ .zIndex(tagNavDragItem == tag.name ? 1 : 0)
+ .highPriorityGesture(tagNavReorderGesture(for: tag.name))
.onHover { hovering in
- if hovering { scrollTo(tag.id) }
+ handleTagNavigationHover(tag.id, active: hovering)
}
}
- }.padding(.horizontal, 24)
+ }
+ .padding(.leading, 24)
+ .padding(.trailing, tagPosition == "top" ? floatingControlsReservedWidth : 24)
+ .coordinateSpace(name: "tagNavReorder")
+ .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in
+ tagNavReorderFrames = frames
+ }
}
}
- private var tagSidebar: some View {
+ private var swiftUITagSidebarList: some View {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 6) {
ForEach(tagLabels) { tag in
SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
+ dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
+ isDragging: tagNavDragItem == tag.name,
action: {
- scrollTo(tag.id)
+ activateTagNavigation(tag.id)
})
+ .background(tagNavFrameReader(for: tag.name))
+ .zIndex(tagNavDragItem == tag.name ? 1 : 0)
+ .highPriorityGesture(tagNavReorderGesture(for: tag.name))
.onHover { hovering in
- if hovering { scrollTo(tag.id) }
+ handleTagNavigationHover(tag.id, active: hovering)
}
}
- }.padding(12)
- }.frame(width: 135)
+ }
+ .padding(12)
+ .coordinateSpace(name: "tagNavReorder")
+ .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in
+ tagNavReorderFrames = frames
+ }
+ }
}
private var sideDivider: some View {
@@ -373,138 +1020,162 @@
Spacer()
ProgressView().scaleEffect(0.8)
Spacer()
- } else if displayMode == "container" || displayMode == "coloredContainer" {
- containerGrid
} else {
- flatGrid
+ AppGridCollectionView(
+ groups: displayGroups,
+ tagColors: tagColors,
+ displayMode: displayMode,
+ iconSize: iconSize,
+ showNames: !hideAppNames,
+ bubbleDisabled: appBubbleDisabled,
+ showUncommonAppBubbles: showUncommonAppBubbles,
+ highlightedGroupName: appGridHighlightedGroupName,
+ contentRevision: groupLayoutVersion,
+ scrollTargetID: appGridScrollTargetID,
+ scrollRequestToken: appGridScrollRequestToken,
+ onSelectApp: { app in openApp(app) },
+ onBubbleHover: handleBubbleHover,
+ onEditNote: beginEditingBubbleNote,
+ onDropApp: { path, source, target, copy in
+ dropApp(path: path, sourceTag: source, targetTag: target, copy: copy)
+ },
+ onDropOutsideGroup: { path, source, copy in
+ dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
+ },
+ onGroupActivate: { groupName in
+ if isColorlessContainerMode {
+ toggleColorlessFill(groupName)
+ }
+ },
+ onScrollActivity: handleAppGridScrollActivity,
+ onDragModeChange: { setAppDragMode($0) }
+ )
}
}
}
- private var flatGrid: some View {
- ScrollViewReader { proxy in
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 24) {
- ForEach(groups) { group in
- TagGroupView(
- group: group,
- onSelectApp: { app in openApp(app) },
- tagFontSize: tagFontSize,
- iconSize: iconSize,
- showNames: !hideAppNames
- ).id(group.id)
- }
- }.padding(20)
+ private var uncommonAppBubbleOverlay: some View {
+ GeometryReader { proxy in
+ if let context = appGridInteraction.editingBubble ?? appGridInteraction.hoveredBubble {
+ let rootFrame = proxy.frame(in: .global)
+ let editing = appGridInteraction.editingBubble != nil
+ let width = min(editing ? 440 : 520, max(260, proxy.size.width - 48))
+ let placement = bubblePlacement(for: context.frame, rootFrame: rootFrame)
+ let metrics = bubbleMetrics(
+ for: context,
+ rootFrame: rootFrame,
+ rootSize: proxy.size,
+ width: width,
+ placement: placement,
+ isEditing: editing
+ )
+
+ AppNameBubble(
+ appName: context.app.displayName,
+ note: currentNote(for: context.app),
+ isEditing: editing,
+ placement: placement,
+ arrowOffset: metrics.arrowOffset,
+ draftNote: $appGridInteraction.bubbleDraftNote,
+ noteFocused: $bubbleNoteFocused,
+ onCommit: commitBubbleNote,
+ onCancel: dismissAppBubble
+ )
+ .frame(width: width)
+ .fixedSize(horizontal: false, vertical: true)
+ .position(x: metrics.centerX, y: metrics.centerY)
+ .transition(.opacity)
+ .zIndex(500)
}
- .id(displayMode) // force rebuild on mode switch
- .onAppear { scrollProxy = proxy }
}
+ .ignoresSafeArea()
+ .allowsHitTesting(appGridInteraction.editingBubble != nil)
}
- private var containerGrid: some View {
- GeometryReader { geo in
- let outerPad: CGFloat = 20
- let gap: CGFloat = 16
- let available = geo.size.width - outerPad * 2
- let colW: CGFloat = 280
- let colCount = max(1, Int((available + gap) / (colW + gap)))
- let actualColW = (available - gap * CGFloat(colCount - 1)) / CGFloat(colCount)
+ private var smartStartNoticeOverlay: some View {
+ GeometryReader { proxy in
+ if let notice = smartStartNotice {
+ let panelWidth = min(560, max(320, proxy.size.width - 64))
- let columns = distributeToColumns(groups: groups, colCount: colCount, colWidth: actualColW)
+ ZStack {
+ Color.black.opacity(0.10)
+ .ignoresSafeArea()
- ScrollViewReader { proxy in
- ScrollView {
- HStack(alignment: .top, spacing: gap) {
- ForEach(0..<colCount, id: \.self) { ci in
- LazyVStack(spacing: gap) {
- ForEach(columns[ci]) { group in
- masonryCard(group, width: actualColW)
- .id(group.id)
+ VStack(spacing: 18) {
+ Image(systemName: notice.mode == .suggestionOnly ? "sparkles" : "checkmark.seal.fill")
+ .font(.system(size: 30, weight: .semibold))
+ .foregroundStyle(Color.accentColor)
+ .frame(width: 58, height: 58)
+ .background(
+ Circle()
+ .fill(Color.accentColor.opacity(0.12))
+ )
+
+ VStack(spacing: 8) {
+ Text(notice.title)
+ .font(.system(size: 21, weight: .semibold))
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.center)
+
+ Text(notice.message)
+ .font(.system(size: 15, weight: .regular))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .lineLimit(4)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ HStack(spacing: 12) {
+ switch notice.mode {
+ case .suggestionOnly:
+ Button(tr("smartstart.later")) {
+ dismissSmartStartNotice()
}
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+
+ Button(tr("smartstart.apply")) {
+ applySmartStartSuggestion()
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ case .autoApplied, .manuallyApplied:
+ if notice.summary.backupPath != nil {
+ Button(tr("smartstart.undo")) {
+ undoSmartStart()
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ }
+ Button(tr("smartstart.ok")) {
+ dismissSmartStartNotice()
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
}
}
}
- .padding(outerPad)
+ .padding(.horizontal, 30)
+ .padding(.vertical, 26)
+ .frame(width: panelWidth)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(.ultraThickMaterial)
+ .shadow(color: .black.opacity(0.26), radius: 28, y: 16)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .stroke(.white.opacity(0.16), lineWidth: 1)
+ )
}
- .id(displayMode) // force rebuild on mode switch
- .onAppear { scrollProxy = proxy }
+ .frame(width: proxy.size.width, height: proxy.size.height)
+ .transition(.scale(scale: 0.96).combined(with: .opacity))
+ .zIndex(650)
}
}
+ .ignoresSafeArea()
+ .allowsHitTesting(smartStartNotice != nil)
}
-
- /// Distribute groups to the shortest column.
- private func distributeToColumns(groups: [TagGroup], colCount: Int, colWidth: CGFloat) -> [[TagGroup]] {
- var cols = Array(repeating: [TagGroup](), count: colCount)
- var h = Array(repeating: CGFloat(0), count: colCount)
- for g in groups {
- let est = estimatedCardHeight(g, width: colWidth)
- let ci = h.firstIndex(of: h.min()!)!
- cols[ci].append(g)
- h[ci] += est + 16
- }
- return cols
- }
-
- private func estimatedCardHeight(_ group: TagGroup, width: CGFloat) -> CGFloat {
- let inner = width - 32
- let itemW = iconSize + 28 + 6
- let perRow = max(1, Int(inner / itemW))
- let rows = (group.apps.count + perRow - 1) / perRow
- return 32 + CGFloat(rows) * (iconSize + 30)
- }
-
- private func masonryCard(_ group: TagGroup, width: CGFloat) -> some View {
- let isColored = displayMode == "coloredContainer"
- let isHovered = hoveredContainer == group.name
- let tagColor = Color(nsColor: TagColor.nsColor(for: tagColors[group.name] ?? 0))
- return VStack(alignment: .leading, spacing: 6) {
- HStack(spacing: 0) {
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
- Text(group.name)
- .font(.system(size: tagFontSize, weight: .semibold))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- .padding(.horizontal, 10)
- .layoutPriority(1)
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
- }
- let itemSize = iconSize + 28
- LazyVGrid(
- columns: [GridItem(.adaptive(minimum: itemSize, maximum: itemSize + 36), spacing: 6)],
- spacing: 2
- ) {
- ForEach(group.apps) { app in
- AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) })
- }
- }
- }
- .frame(maxWidth: width)
- .padding(16)
- .background(
- RoundedRectangle(cornerRadius: 14)
- .fill(isColored ? tagColor.opacity(0.30) : Color.clear)
- .background(
- RoundedRectangle(cornerRadius: 14)
- .fill(.ultraThinMaterial)
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 14)
- .stroke(Color.primary.opacity(0.08), lineWidth: 1)
- )
- .shadow(color: .black.opacity(isColored && isHovered ? 0.22 : 0),
- radius: isColored && isHovered ? 18 : 0,
- y: isColored && isHovered ? 10 : 0)
- .scaleEffect(isColored && isHovered ? 1.015 : 1.0)
- .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isHovered)
- .onHover { hovering in
- hoveredContainer = hovering ? group.name : nil
- }
- }
-
// MARK: - Edit Tags View
private var editTagsView: some View {
@@ -539,55 +1210,56 @@
private var editAppsView: some View {
VStack(spacing: 0) {
- HStack {
- Button { cancelEditApps(); setEditPhase(.none) } label: {
- Label(tr("edit.exit"), systemImage: "rectangle.portrait.and.arrow.right")
- .font(.system(size: 12))
- }
- .buttonStyle(.bordered)
- Spacer()
- Text(tr("edit.title")).font(.headline)
- Spacer()
- Button(tr("edit.confirm")) { confirmAssign() }
- .buttonStyle(.borderedProminent)
- .disabled(selectedAppPaths.isEmpty || selectedTagNames.isEmpty)
- }
- .padding(.horizontal, 24)
- .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20)
- .padding(.bottom, 12)
+ EditAppsHeaderView(
+ operation: editTagOperation,
+ hintText: editModeHintText,
+ confirmTitle: editConfirmTitle,
+ isConfirmDisabled: selectedAppPaths.isEmpty || pendingOperationTagNames.isEmpty,
+ notchHeight: notchHeight,
+ onExit: {
+ cancelEditApps()
+ setEditPhase(.none)
+ },
+ onSelectOperation: setEditTagOperation,
+ onConfirm: confirmAssign
+ )
Divider().opacity(0.3)
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
- Text(tr("edit.selectTags")).font(.caption).foregroundStyle(.secondary).padding(.bottom, 4)
- Text(tr("edit.dragHint")).font(.caption2).foregroundStyle(.tertiary).padding(.bottom, 2)
+ EditAppsSidebarIntroView(
+ width: editSidebarWidth,
+ horizontalInset: editSidebarHorizontalInset
+ )
+
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 4) {
ForEach(sortedTagNames, id: \.self) { tagName in
selectableTagItem(tagName)
- .onDrag {
- dragItem = tagName
- return NSItemProvider(object: tagName as NSString)
- }
- .onDrop(of: [.text], isTargeted: nil) { providers, _ in
- guard let fromName = dragItem,
- var names = draggedTagNames as [String]?,
- let fromIdx = names.firstIndex(of: fromName),
- let toIdx = names.firstIndex(of: tagName),
- fromIdx != toIdx
- else { return false }
- let toOffset = toIdx > fromIdx ? toIdx + 1 : toIdx
- names.move(fromOffsets: [fromIdx], toOffset: toOffset)
- draggedTagNames = names
- TagEditor.reorderTags(names)
- dragItem = nil
- return true
- }
+ .opacity(dragItem == tagName ? 0.72 : 1.0)
+ .scaleEffect(dragItem == tagName ? 1.03 : 1.0)
+ .background(
+ GeometryReader { proxy in
+ Color.clear.preference(
+ key: TagReorderFramePreferenceKey.self,
+ value: [tagName: proxy.frame(in: .named("tagReorderList"))]
+ )
+ }
+ )
+ .zIndex(dragItem == tagName ? 1 : 0)
+ .highPriorityGesture(tagReorderGesture(for: tagName))
}
- }.padding(12)
- }.frame(width: 155)
+ }
+ .padding(12)
+ .coordinateSpace(name: "tagReorderList")
+ .onPreferenceChange(TagReorderFramePreferenceKey.self) { frames in
+ tagReorderFrames = frames
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
+ .frame(width: editSidebarWidth, alignment: .topLeading)
Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1)
if allApps.isEmpty {
@@ -597,137 +1269,491 @@
}
}
- if let msg = successToast {
- Text(msg).font(.headline)
- .padding(.horizontal, 20).padding(.vertical, 10)
- .background(RoundedRectangle(cornerRadius: 10).fill(.ultraThickMaterial))
- .transition(.move(edge: .bottom).combined(with: .opacity))
- .onAppear {
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
- withAnimation { successToast = nil }
- }
- }
- }
}
}
private var editAppsGrid: some View {
- GeometryReader { geo in
- let outerPad: CGFloat = 20
- let gap: CGFloat = 16
- let available = geo.size.width - outerPad * 2
- let colW: CGFloat = 300
- 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)
-
- ScrollView {
- HStack(alignment: .top, spacing: gap) {
- ForEach(0..<colCount, id: \.self) { ci in
- LazyVStack(spacing: gap) {
- ForEach(columns[ci]) { group in
- editGroupCard(group, width: actualColW)
- }
- }
- }
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 24) {
+ ForEach(editGroups) { group in
+ editFlatGroup(group)
}
- .padding(outerPad)
- .frame(maxWidth: .infinity, alignment: .topLeading)
}
+ .padding(20)
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
- private func editGroupCard(_ group: TagGroup, width: CGFloat) -> some View {
- VStack(alignment: .leading, spacing: 6) {
+ private func editFlatGroup(_ group: TagGroup) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
Text(group.name)
.font(.system(size: tagFontSize, weight: .semibold))
.foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
.padding(.horizontal, 10)
- .layoutPriority(1)
Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
- .layoutPriority(0)
}
+ .padding(.bottom, 6)
+
LazyVGrid(
- columns: [GridItem(.adaptive(minimum: iconSize + 28, maximum: iconSize + 64), spacing: 6)],
+ columns: [
+ GridItem(
+ .adaptive(
+ minimum: AppGridItemMetrics.stableWidth(iconSize: iconSize),
+ maximum: AppGridItemMetrics.stableWidth(iconSize: iconSize) + 36
+ ),
+ spacing: 6
+ )
+ ],
spacing: 2
) {
ForEach(group.apps) { app in editableAppItem(app) }
}
+ .frame(minHeight: group.apps.isEmpty ? max(52, iconSize + 12) : 0)
}
- .frame(maxWidth: width)
- .padding(16)
- .background(
- RoundedRectangle(cornerRadius: 14)
- .fill(.ultraThinMaterial)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 14)
- .stroke(Color.primary.opacity(0.08), lineWidth: 1)
- )
}
private func selectableTagItem(_ tagName: String) -> some View {
let isSelected = selectedTagNames.contains(tagName)
- return HStack(spacing: 6) {
- Circle()
- .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3))
- .frame(width: 16, height: 16)
- .overlay(isSelected ? Image(systemName: "checkmark").font(.system(size: 8, weight: .bold)).foregroundStyle(.white) : nil)
- Text(tagName).font(.system(size: 13, weight: .medium)).foregroundStyle(.primary)
+ let isRemovableCandidate = removableTagNames.contains(tagName)
+ let isUncommon = tagName == TagDatabase.uncommonTagKey
+ let colorIndex = isUncommon ? 0 : (tagColors[tagName] ?? 0)
+ return EditSelectableTagItem(
+ tagName: tagName,
+ displayName: displayTagName(tagName),
+ colorIndex: colorIndex,
+ operation: editTagOperation,
+ isSelected: isSelected,
+ isRemovableCandidate: isRemovableCandidate
+ ) {
+ if isSelected {
+ selectedTagNames.remove(tagName)
+ } else {
+ selectedTagNames.insert(tagName)
+ }
}
- .padding(.horizontal, 10).padding(.vertical, 5)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(RoundedRectangle(cornerRadius: 6)
- .fill(Color(nsColor: TagColor.nsColor(for: tagColors[tagName] ?? 0).withAlphaComponent(0.3))))
- .contentShape(RoundedRectangle(cornerRadius: 6))
- .onTapGesture {
- if isSelected { selectedTagNames.remove(tagName) } else { selectedTagNames.insert(tagName) }
+ }
+
+ private func tagReorderGesture(for tagName: String) -> some Gesture {
+ DragGesture(minimumDistance: 4, coordinateSpace: .named("tagReorderList"))
+ .onChanged { value in
+ guard canReorderTag(tagName) else { return }
+ if dragItem == nil {
+ dragItem = tagName
+ }
+ guard dragItem == tagName else { return }
+ reorderDraggedTag(at: value.location)
+ }
+ .onEnded { _ in
+ guard canReorderTag(tagName) else { return }
+ dragItem = nil
+ TagEditor.reorderTags(draggedTagNames)
+ }
+ }
+
+ private func reorderDraggedTag(at location: CGPoint) {
+ guard let fromName = dragItem,
+ let targetName = tagReorderFrames.first(where: { $0.value.contains(location) })?.key,
+ fromName != targetName,
+ let fromIndex = draggedTagNames.firstIndex(of: fromName),
+ let toIndex = draggedTagNames.firstIndex(of: targetName)
+ else { return }
+
+ withAnimation(.easeInOut(duration: 0.14)) {
+ let destination = toIndex > fromIndex ? toIndex + 1 : toIndex
+ draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination)
}
}
private func confirmAssign() {
- guard !selectedAppPaths.isEmpty, !selectedTagNames.isEmpty else { return }
- let paths = selectedAppPaths.map { $0.path }
- for tagName in selectedTagNames {
- TagEditor.assignTag(tagName, color: tagColors[tagName] ?? 0, to: paths)
+ let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) }
+ let tags = pendingOperationTagNames
+ guard !selectedApps.isEmpty, !tags.isEmpty else { return }
+
+ let paths = selectedApps.map { $0.path.path }
+ let feedback = buildEditActionFeedback(for: selectedApps, tags: tags)
+
+ switch editTagOperation {
+ case .add:
+ TagEditor.appendTags(tags, to: paths)
+ case .remove:
+ TagEditor.removeTags(tags, from: paths)
}
- selectedAppPaths = []; selectedTagNames = []
+ selectedAppPaths = []
+ selectedTagNames = []
+ removableTagNames = []
refreshApps()
- withAnimation { successToast = tr("edit.success") }
+ showEditActionFeedback(feedback)
}
private func cancelEditApps() {
- selectedAppPaths = []; selectedTagNames = []
+ selectedAppPaths = []
+ selectedTagNames = []
+ removableTagNames = []
+ editTagOperation = .add
}
private func editableAppItem(_ app: AppInfo) -> some View {
let isSelected = selectedAppPaths.contains(app.path)
- return Button {
- if isSelected { selectedAppPaths.remove(app.path) } else { selectedAppPaths.insert(app.path) }
- } label: {
- VStack(spacing: 4) {
- ZStack(alignment: .topTrailing) {
- Image(nsImage: app.icon).resizable().aspectRatio(contentMode: .fit)
- .frame(width: iconSize, height: iconSize)
- Circle()
- .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3))
- .frame(width: 20, height: 20)
- .overlay(isSelected ? Image(systemName: "checkmark").font(.system(size: 10, weight: .bold)).foregroundStyle(.white) : nil)
- .offset(x: 6, y: -6)
- }
- Text(app.name).font(.system(size: 11, weight: .medium)).lineLimit(1).truncationMode(.tail)
- .frame(maxWidth: iconSize + 20)
- }
- .padding(.vertical, 8).padding(.horizontal, 4)
- .contentShape(RoundedRectangle(cornerRadius: 10))
- .opacity(isSelected ? 1.0 : 0.65)
+ return EditableAppSelectionItem(
+ app: app,
+ iconSize: iconSize,
+ isSelected: isSelected
+ ) {
+ toggleEditableAppSelection(app)
}
- .buttonStyle(.plain)
+ }
+
+ private func toggleEditableAppSelection(_ app: AppInfo) {
+ if selectedAppPaths.contains(app.path) {
+ selectedAppPaths.remove(app.path)
+ } else {
+ selectedAppPaths.insert(app.path)
+ }
+ syncSelectedTagsFromSelectedApps()
+ }
+
+ private func syncSelectedTagsFromSelectedApps() {
+ guard !selectedAppPaths.isEmpty else {
+ selectedTagNames = []
+ removableTagNames = []
+ return
+ }
+
+ guard editTagOperation == .remove else {
+ removableTagNames = []
+ return
+ }
+
+ let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) }
+ let autoSelectedTags = selectedApps.reduce(into: Set<String>()) { result, app in
+ result.formUnion(app.tags)
+ if app.isUncommon {
+ result.insert(TagDatabase.uncommonTagKey)
+ }
+ }
+ removableTagNames = autoSelectedTags
+ selectedTagNames = autoSelectedTags
+ }
+
+ private var editConfirmTitle: String {
+ switch editTagOperation {
+ case .add:
+ return tr("edit.confirmAdd")
+ case .remove:
+ return tr("edit.confirmRemove")
+ }
+ }
+
+ private func setEditTagOperation(_ mode: EditTagOperation) {
+ guard editTagOperation != mode else { return }
+ editTagOperation = mode
+ if mode == .add {
+ selectedTagNames = []
+ removableTagNames = []
+ } else {
+ syncSelectedTagsFromSelectedApps()
+ }
+ }
+
+ private var pendingOperationTagNames: [String] {
+ switch editTagOperation {
+ case .add:
+ return sortedTagNames.filter { selectedTagNames.contains($0) }
+ case .remove:
+ return sortedTagNames.filter { removableTagNames.contains($0) && !selectedTagNames.contains($0) }
+ }
+ }
+
+ private var editModeHintText: String {
+ editTagOperation == .remove ? tr("edit.removeHint") : tr("edit.modeHint")
+ }
+
+ private var editActionFeedbackOverlay: some View {
+ GeometryReader { proxy in
+ if let feedback = editActionFeedback {
+ ZStack {
+ Color.black.opacity(0.08)
+ .ignoresSafeArea()
+
+ EditActionFeedbackBubble(
+ title: feedback.title,
+ message: feedback.message,
+ onClose: dismissEditActionFeedback
+ )
+ .frame(width: min(760, max(360, proxy.size.width - 120)))
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ }
+ .zIndex(700)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(editActionFeedback != nil)
+ }
+
+ private var uncategorizedDropConfirmOverlay: some View {
+ GeometryReader { proxy in
+ if let pendingDrop = appGridInteraction.pendingUncategorizedDrop {
+ ZStack {
+ Color.black.opacity(0.14)
+ .ignoresSafeArea()
+
+ UncategorizedDropConfirmBubble(
+ title: tr("drop.uncategorizedConfirmTitle"),
+ message: uncategorizedConfirmMessage(for: pendingDrop),
+ cancelTitle: tr("tag.cancel"),
+ confirmTitle: tr("edit.confirm"),
+ onCancel: dismissUncategorizedDropConfirm,
+ onConfirm: confirmPendingUncategorizedDrop
+ )
+ .frame(width: min(540, max(340, proxy.size.width - 120)))
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ }
+ .zIndex(710)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(appGridInteraction.pendingUncategorizedDrop != nil)
+ }
+
+ private var tagRemovalDropConfirmOverlay: some View {
+ GeometryReader { proxy in
+ if let pendingDrop = appGridInteraction.pendingTagRemovalDrop {
+ ZStack {
+ Color.black.opacity(0.14)
+ .ignoresSafeArea()
+
+ TagRemovalDropConfirmBubble(
+ title: tr("drop.removeTagConfirmTitle"),
+ message: tagRemovalConfirmMessage(for: pendingDrop),
+ doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"),
+ doNotRemind: $appGridInteraction.tagRemovalDropSuppressFuturePrompt,
+ cancelTitle: tr("drop.removeTagConfirmNo"),
+ confirmTitle: tr("drop.removeTagConfirmYes"),
+ onCancel: dismissTagRemovalDropConfirm,
+ onConfirm: confirmPendingTagRemovalDrop
+ )
+ .frame(width: min(560, max(350, proxy.size.width - 120)))
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ }
+ .zIndex(711)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(appGridInteraction.pendingTagRemovalDrop != nil)
+ }
+
+ private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
+ let appNames = selectedApps.map(\.name)
+ .joined(separator: localizedListSeparator)
+
+ switch editTagOperation {
+ case .add:
+ let actualAddCount = selectedApps.reduce(into: 0) { count, app in
+ count += tags.reduce(into: 0) { subtotal, tagName in
+ if !appHasTag(app, tagName: tagName) {
+ subtotal += 1
+ }
+ }
+ }
+
+ return EditActionFeedback(
+ title: tr("edit.confirmAdd"),
+ message: formattedFeedbackMessage(
+ forKey: "edit.feedbackAddFormat",
+ replacements: [
+ "%appCount%": "\(selectedApps.count)",
+ "%appNames%": appNames,
+ "%tagCount%": "\(actualAddCount)"
+ ]
+ )
+ )
+
+ case .remove:
+ let actualRemovedTagNames = tags.filter { tagName in
+ selectedApps.contains(where: { appHasTag($0, tagName: tagName) })
+ }
+ let actualRemoveCount = selectedApps.reduce(into: 0) { count, app in
+ count += tags.reduce(into: 0) { subtotal, tagName in
+ if appHasTag(app, tagName: tagName) {
+ subtotal += 1
+ }
+ }
+ }
+
+ return EditActionFeedback(
+ title: tr("edit.confirmRemove"),
+ message: formattedFeedbackMessage(
+ forKey: "edit.feedbackRemoveFormat",
+ replacements: [
+ "%appCount%": "\(selectedApps.count)",
+ "%appNames%": appNames,
+ "%tagCount%": "\(actualRemoveCount)",
+ "%tagNames%": actualRemovedTagNames
+ .map(displayTagName)
+ .joined(separator: localizedListSeparator)
+ ]
+ )
+ )
+ }
+ }
+
+ private func appHasTag(_ app: AppInfo, tagName: String) -> Bool {
+ if tagName == TagDatabase.uncommonTagKey {
+ return app.isUncommon
+ }
+ return app.tags.contains(tagName)
+ }
+
+ private func formattedFeedbackMessage(forKey key: String, replacements: [String: String]) -> String {
+ replacements.reduce(tr(key)) { partial, replacement in
+ partial.replacingOccurrences(of: replacement.key, with: replacement.value)
+ }
+ }
+
+ private func showEditActionFeedback(_ feedback: EditActionFeedback) {
+ let feedbackID = feedback.id
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
+ editActionFeedback = feedback
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.6) {
+ guard editActionFeedback?.id == feedbackID else { return }
+ dismissEditActionFeedback()
+ }
+ }
+
+ private func dismissEditActionFeedback() {
+ withAnimation(.easeOut(duration: 0.18)) {
+ editActionFeedback = nil
+ }
+ }
+
+ private func showSmartStartNotice(
+ mode: SmartStartNoticeMode,
+ summary: SmartStartSummary
+ ) {
+ let titleKey: String
+ let messageKey: String
+
+ switch mode {
+ case .autoApplied:
+ titleKey = "smartstart.auto.title"
+ messageKey = "smartstart.auto.messageFormat"
+ case .suggestionOnly:
+ titleKey = "smartstart.suggestion.title"
+ messageKey = "smartstart.suggestion.messageFormat"
+ case .manuallyApplied:
+ titleKey = "smartstart.applied.title"
+ messageKey = "smartstart.applied.messageFormat"
+ }
+
+ let notice = SmartStartNotice(
+ mode: mode,
+ title: tr(titleKey),
+ message: formattedFeedbackMessage(
+ forKey: messageKey,
+ replacements: [
+ "%appCount%": "\(summary.matchedAppCount)",
+ "%tagCount%": "\(summary.assignedTagCount)",
+ "%createdTagCount%": "\(summary.createdTagCount)"
+ ]
+ ),
+ summary: summary
+ )
+
+ withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) {
+ smartStartNotice = notice
+ }
+ }
+
+ private func handleSmartStartRunResult(_ result: SmartStartRunResult) {
+ switch result.mode {
+ case .none:
+ return
+ case .autoApplied:
+ pendingSmartStartDraft = nil
+ if let summary = result.summary {
+ showSmartStartNotice(mode: .autoApplied, summary: summary)
+ }
+ case .suggestionOnly:
+ pendingSmartStartDraft = result.draft
+ if let summary = result.summary {
+ showSmartStartNotice(mode: .suggestionOnly, summary: summary)
+ }
+ }
+ }
+
+ private func applySmartStartSuggestion() {
+ guard let draft = pendingSmartStartDraft else {
+ dismissSmartStartNotice()
+ return
+ }
+
+ let scannedApps = allApps
+ DispatchQueue.global(qos: .userInitiated).async {
+ 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 {
+ dismissSmartStartNotice()
+ }
+ }
+ }
+ }
+
+ private func undoSmartStart() {
+ guard let backupPath = smartStartNotice?.summary.backupPath else {
+ dismissSmartStartNotice()
+ return
+ }
+
+ DispatchQueue.global(qos: .userInitiated).async {
+ let restored = SmartStartService.restoreBackup(at: backupPath)
+ DispatchQueue.main.async {
+ pendingSmartStartDraft = nil
+ dismissSmartStartNotice()
+ if restored {
+ refreshApps(forceLayoutRefresh: true)
+ }
+ }
+ }
+ }
+
+ private func dismissSmartStartNotice() {
+ withAnimation(.easeOut(duration: 0.18)) {
+ smartStartNotice = nil
+ }
+ }
+
+ private var localizedListSeparator: String {
+ switch L10n.currentCode {
+ case "zh-Hans", "zh-Hant", "ja":
+ return "、"
+ case "ar", "ar-Najdi":
+ return "، "
+ default:
+ return ", "
+ }
}
// MARK: - Computed
@@ -737,20 +1763,17 @@
// Use user-defined order, fall back to alpha
let ordered = draggedTagNames.filter { filtered.contains($0) }
let remaining = filtered.filter { !ordered.contains($0) }.sorted()
- return ordered + remaining
+ return ordered + remaining + [TagDatabase.uncommonTagKey]
}
- private var tagLabels: [TagLabel] {
- groups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
+ private func displayTagName(_ tagName: String) -> String {
+ tagName == TagDatabase.uncommonTagKey ? tr("group.uncommon") : tagName
}
- 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)
}
@@ -761,39 +1784,671 @@
}
}
- // MARK: - Actions
-
- func scrollTo(_ id: String) {
- withAnimation(.easeInOut(duration: 0.25)) { scrollProxy?.scrollTo(id, anchor: .top) }
+ private func rebuildDisplayGroups(apps: [AppInfo], tagOrder: [String]) {
+ displayGroups = makeDisplayGroups(apps: apps, tagOrder: tagOrder)
+ groupLayoutVersion &+= 1
}
- func refreshApps() {
+ private var tagLabels: [TagNavigationItem] {
+ displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
+ }
+
+ private var editGroups: [TagGroup] {
+ displayGroups + [
+ TagGroup(
+ name: tr("group.uncommon"),
+ apps: allApps.filter(\.isUncommon).sorted {
+ $0.name.localizedStandardCompare($1.name) == .orderedAscending
+ }
+ )
+ ]
+ }
+
+ // 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() {
+ appGridInteraction.hoveredBubble = nil
+ }
+
+ private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
+ guard !appBubbleDisabled else {
+ clearAppBubbleState()
+ return
+ }
+ guard appGridInteraction.editingBubble == nil else { return }
+ switch event {
+ case .entered(let canShowBubble):
+ if canShowBubble {
+ appGridInteraction.hoveredBubble = AppBubbleContext(app: app, frame: frame)
+ } else {
+ appGridInteraction.hoveredBubble = nil
+ }
+ case .exited:
+ guard appGridInteraction.hoveredBubble?.app.path == app.path else { return }
+ appGridInteraction.hoveredBubble = nil
+ }
+ }
+
+ private func beginEditingBubbleNote(app: AppInfo, frame: CGRect) {
+ guard !appBubbleDisabled else {
+ clearAppBubbleState()
+ return
+ }
+ appGridInteraction.bubbleDraftNote = currentNote(for: app)
+ appGridInteraction.hoveredBubble = nil
+ appGridInteraction.editingBubble = AppBubbleContext(app: app, frame: frame)
+ notifyAppNoteEditing(active: true)
+ DispatchQueue.main.async {
+ bubbleNoteFocused = true
+ }
+ }
+
+ private func commitBubbleNote() {
+ guard let context = appGridInteraction.editingBubble else { return }
+ let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ TagEditor.setAppNote(limited, for: context.app.path.path)
+ appGridInteraction.bubbleDraftNote = limited
+ appGridInteraction.editingBubble = nil
+ bubbleNoteFocused = false
+ notifyAppNoteEditing(active: false)
+ refreshApps()
+ }
+
+ private func dismissAppBubble() {
+ appGridInteraction.hoveredBubble = nil
+ if appGridInteraction.editingBubble != nil {
+ notifyAppNoteEditing(active: false)
+ }
+ appGridInteraction.editingBubble = nil
+ bubbleNoteFocused = false
+ }
+
+ private func clearAppBubbleState() {
+ if appGridInteraction.hoveredBubble != nil {
+ appGridInteraction.hoveredBubble = nil
+ }
+ if appGridInteraction.editingBubble != nil {
+ notifyAppNoteEditing(active: false)
+ appGridInteraction.editingBubble = nil
+ }
+ if bubbleNoteFocused {
+ bubbleNoteFocused = false
+ }
+ }
+
+ private func notifyAppNoteEditing(active: Bool) {
+ NotificationCenter.default.post(
+ name: .tagLauncherAppNoteEditingChanged,
+ object: nil,
+ userInfo: ["active": active]
+ )
+ }
+
+ private func currentNote(for app: AppInfo) -> String {
+ if let latest = allApps.first(where: { $0.path == app.path })?.note {
+ return latest
+ }
+ return app.note ?? ""
+ }
+
+ private func bubblePlacement(for frame: CGRect, rootFrame: CGRect) -> BubblePlacement {
+ frame.minY < 170 ? .below : .above
+ }
+
+ private func bubbleMetrics(
+ for context: AppBubbleContext,
+ rootFrame: CGRect,
+ rootSize: CGSize,
+ width: CGFloat,
+ placement: BubblePlacement,
+ isEditing: Bool
+ ) -> AppBubbleMetrics {
+ let appCenterX = context.frame.midX
+ let halfWidth = width / 2
+ let centerX = min(max(appCenterX, halfWidth + 24), max(halfWidth + 24, rootSize.width - halfWidth - 24))
+ let estimatedHeight = estimatedBubbleHeight(for: context.app, width: width, isEditing: isEditing)
+ let gap: CGFloat = 16
+ let topY: CGFloat
+ if placement == .below {
+ topY = context.frame.maxY + gap
+ } else {
+ topY = context.frame.minY - estimatedHeight - gap
+ }
+ let clampedTopY = min(max(topY, 14), max(14, rootSize.height - estimatedHeight - 14))
+ let arrowX = min(max(appCenterX, centerX - halfWidth + 28), centerX + halfWidth - 28)
+ return AppBubbleMetrics(
+ centerX: centerX,
+ centerY: clampedTopY + estimatedHeight / 2,
+ arrowX: arrowX,
+ arrowOffset: arrowX - centerX
+ )
+ }
+
+ private func estimatedBubbleHeight(for app: AppInfo, width: CGFloat, isEditing: Bool) -> CGFloat {
+ if isEditing {
+ return 120
+ }
+ let note = currentNote(for: app).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !note.isEmpty else { return 68 }
+ let contentWidth = max(160, width - 48)
+ let approxCharsPerLine = max(16, Int(contentWidth / 14))
+ let lines = min(4, max(1, Int(ceil(Double(note.count) / Double(approxCharsPerLine)))))
+ return 70 + CGFloat(lines) * 20
+ }
+
+ func scrollTo(_ id: String) {
+ appGridScrollTargetID = id
+ appGridScrollRequestToken &+= 1
+ }
+
+ private func activateTagNavigation(_ id: String) {
+ cancelTagNavReorderVisualState()
+ if isColorlessContainerMode {
+ toggleColorlessFill(id)
+ }
+ scrollTo(id)
+ }
+
+ private func handleTagNavigationHover(_ id: String, active: Bool) {
+ guard active else {
+ if appGridInteraction.tagNavigationHoveredGroupName == id {
+ appGridInteraction.tagNavigationHoveredGroupName = nil
+ }
+ return
+ }
+
+ appGridInteraction.tagNavigationHoveredGroupName = id
+ fillColorlessContainer(id)
+ scrollToTagFromHover(id)
+ }
+
+ private func scrollToTagFromHover(_ id: String) {
+ let now = Date()
+ if appGridInteraction.tagNavigationLastHoverScrollID == id,
+ let lastScrollAt = appGridInteraction.tagNavigationLastHoverScrollAt,
+ now.timeIntervalSince(lastScrollAt) < tagNavigationHoverScrollInterval {
+ return
+ }
+ appGridInteraction.tagNavigationLastHoverScrollID = id
+ appGridInteraction.tagNavigationLastHoverScrollAt = now
+ scrollTo(id)
+ }
+
+ private func fillColorlessContainer(_ id: String) {
+ guard isColorlessContainerMode else { return }
+ guard filledColorlessContainer != id else { return }
+ filledColorlessContainer = id
+ }
+
+ private func toggleColorlessFill(_ id: String) {
+ guard isColorlessContainerMode else { return }
+ filledColorlessContainer = (filledColorlessContainer == id) ? nil : id
+ }
+
+ private func canReorderTag(_ name: String) -> Bool {
+ tagColors[name] != nil && name != "Mac自带" && name != defaultGroupName
+ }
+
+ private func tagNavFrameReader(for tagName: String) -> some View {
+ GeometryReader { proxy in
+ Color.clear.preference(
+ key: TagNavReorderFramePreferenceKey.self,
+ value: canReorderTag(tagName)
+ ? [tagName: proxy.frame(in: .named("tagNavReorder"))]
+ : [:]
+ )
+ }
+ }
+
+ private func tagNavReorderGesture(for tagName: String) -> some Gesture {
+ LongPressGesture(minimumDuration: 0.35)
+ .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named("tagNavReorder")))
+ .onChanged { value in
+ guard canReorderTag(tagName) else { return }
+ switch value {
+ case .second(true, let drag?):
+ if tagNavDragItem == nil {
+ beginTagNavReorder(tagName)
+ }
+ reorderTagNavItem(at: drag.location)
+ default:
+ break
+ }
+ }
+ .onEnded { value in
+ guard canReorderTag(tagName) else { return }
+ if case .second(true, let drag?) = value {
+ reorderTagNavItem(at: drag.location)
+ }
+ endTagNavReorder()
+ }
+ }
+
+ private func beginTagNavReorder(_ tagName: String) {
+ guard canReorderTag(tagName) else { return }
+ if tagNavDragItem == nil {
+ tagNavDragItem = tagName
+ tagNavReorderDidMove = false
+ }
+ tagNavDragModeActive = true
+ }
+
+ private func endTagNavReorder() {
+ let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
+ guard hadDragState else { return }
+ if tagNavDragModeActive && tagNavReorderDidMove {
+ TagEditor.reorderTags(draggedTagNames)
+ }
+ tagNavDragModeActive = false
+ tagNavDragItem = nil
+ tagNavReorderDidMove = false
+ }
+
+ private func cancelTagNavReorderVisualState() {
+ guard tagNavDragModeActive || tagNavDragItem != nil else { return }
+ tagNavDragModeActive = false
+ tagNavDragItem = nil
+ tagNavReorderDidMove = false
+ }
+
+ private func reorderTagNavItem(at location: CGPoint) {
+ guard let fromName = tagNavDragItem,
+ let targetName = tagNavReorderFrames.first(where: { $0.value.contains(location) })?.key,
+ fromName != targetName
+ else { return }
+ reorderTagNavItem(fromName: fromName, to: targetName)
+ }
+
+ private func reorderTagNavItem(fromName: String, to targetName: String) {
+ guard fromName != targetName,
+ canReorderTag(fromName),
+ canReorderTag(targetName),
+ let fromIndex = draggedTagNames.firstIndex(of: fromName),
+ let toIndex = draggedTagNames.firstIndex(of: targetName)
+ else { return }
+ tagNavReorderDidMove = true
+ withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) {
+ let destination = toIndex > fromIndex ? toIndex + 1 : toIndex
+ draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination)
+ }
+ }
+
+ private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
+ resetTransientDragState(keepingPendingUncategorizedDrop: true)
+
+ if isUncategorizedDropTarget(targetTag) {
+ confirmAndMoveAppToUncategorized(path: path)
+ return
+ }
+
+ guard !isAppleBuiltInDropTarget(targetTag) else {
+ showDropWarning()
+ return
+ }
+
+ guard tagColors[targetTag] != nil else { return }
+ guard sourceTag != targetTag || copy else { return }
+ TagEditor.moveApp(
+ path: path,
+ from: sourceTag,
+ to: targetTag,
+ color: tagColors[targetTag] ?? 0,
+ copy: copy
+ )
+ showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func dropAppOutsideGroup(path: String, sourceTag: String, copy: Bool) {
+ resetTransientDragState(keepingPendingTagRemovalDrop: true)
+ guard isRemovableRegularTag(sourceTag) else { return }
+ guard let app = allApps.first(where: { $0.path.path == path }),
+ appHasTag(app, tagName: sourceTag)
+ else { return }
+
+ if skipTagRemovalDropConfirm {
+ removeTagFromDroppedApp(app: app, tagName: sourceTag)
+ return
+ }
+
+ clearAppBubbleState()
+ appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
+ appGridInteraction.pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag)
+ }
+ }
+
+ private func confirmAndMoveAppToUncategorized(path: String) {
+ guard let app = allApps.first(where: { $0.path.path == path }) else { return }
+ let removableTags = removableRegularTags(for: app)
+ guard !removableTags.isEmpty else { return }
+ let assignedTags = assignedRegularDisplayTags(for: removableTags)
+
+ clearAppBubbleState()
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
+ appGridInteraction.pendingUncategorizedDrop = PendingUncategorizedDrop(
+ app: app,
+ assignedTags: assignedTags,
+ removableTags: removableTags
+ )
+ }
+ }
+
+ private func removableRegularTags(for app: AppInfo) -> [String] {
+ var result: [String] = []
+ for tag in app.tags {
+ guard isRemovableRegularTag(tag) else { continue }
+ if !result.contains(tag) {
+ result.append(tag)
+ }
+ }
+ return result
+ }
+
+ private func assignedRegularDisplayTags(for tags: [String]) -> [String] {
+ var result: [String] = []
+ for tag in tags {
+ let name = displayTagName(tag)
+ if !result.contains(name) {
+ result.append(name)
+ }
+ }
+ return result
+ }
+
+ private func uncategorizedConfirmMessage(for pendingDrop: PendingUncategorizedDrop) -> String {
+ formattedFeedbackMessage(
+ forKey: "drop.uncategorizedConfirmMessage",
+ replacements: [
+ "%appName%": pendingDrop.app.displayName,
+ "%tagCount%": "\(pendingDrop.assignedTags.count)",
+ "%tagNames%": pendingDrop.assignedTags.joined(separator: localizedListSeparator)
+ ]
+ )
+ }
+
+ private func tagRemovalConfirmMessage(for pendingDrop: PendingTagRemovalDrop) -> String {
+ formattedFeedbackMessage(
+ forKey: "drop.removeTagConfirmMessage",
+ replacements: [
+ "%appName%": pendingDrop.app.displayName,
+ "%tagName%": displayTagName(pendingDrop.tagName)
+ ]
+ )
+ }
+
+ private func dismissUncategorizedDropConfirm() {
+ resetTransientDragState(keepingPendingUncategorizedDrop: true)
+ withAnimation(.easeOut(duration: 0.18)) {
+ appGridInteraction.pendingUncategorizedDrop = nil
+ }
+ }
+
+ private func confirmPendingUncategorizedDrop() {
+ guard let pendingDrop = appGridInteraction.pendingUncategorizedDrop else { return }
+ let path = pendingDrop.app.path.path
+ let tags = pendingDrop.removableTags
+ resetTransientDragState(keepingPendingUncategorizedDrop: true)
+ withAnimation(.easeOut(duration: 0.16)) {
+ appGridInteraction.pendingUncategorizedDrop = nil
+ }
+ guard !tags.isEmpty else { return }
+ TagEditor.removeTags(tags, from: [path])
+ showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func dismissTagRemovalDropConfirm() {
+ appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
+ withAnimation(.easeOut(duration: 0.18)) {
+ appGridInteraction.pendingTagRemovalDrop = nil
+ }
+ }
+
+ private func confirmPendingTagRemovalDrop() {
+ guard let pendingDrop = appGridInteraction.pendingTagRemovalDrop else { return }
+ let app = pendingDrop.app
+ let tagName = pendingDrop.tagName
+ let shouldSuppressFuturePrompt = appGridInteraction.tagRemovalDropSuppressFuturePrompt
+ if shouldSuppressFuturePrompt {
+ skipTagRemovalDropConfirm = true
+ }
+ appGridInteraction.tagRemovalDropSuppressFuturePrompt = false
+ withAnimation(.easeOut(duration: 0.16)) {
+ appGridInteraction.pendingTagRemovalDrop = nil
+ }
+ DispatchQueue.main.async {
+ removeTagFromDroppedApp(app: app, tagName: tagName)
+ }
+ }
+
+ private func removeTagFromDroppedApp(app: AppInfo, tagName: String) {
+ guard isRemovableRegularTag(tagName),
+ appHasTag(app, tagName: tagName)
+ else { return }
+ TagEditor.removeTags([tagName], from: [app.path.path])
+ showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func isUncategorizedDropTarget(_ targetTag: String) -> Bool {
+ let defaultNames = [
+ defaultGroupName,
+ tr("group.uncategorized")
+ ]
+ return defaultNames.contains(targetTag)
+ }
+
+ private func isAppleBuiltInDropTarget(_ targetTag: String) -> Bool {
+ let defaultNames = [
+ "Mac自带",
+ tr("group.appleBuiltIn")
+ ]
+ return defaultNames.contains(targetTag)
+ }
+
+ private func isRemovableRegularTag(_ tag: String) -> Bool {
+ let protectedNames = [
+ "Mac自带",
+ tr("group.appleBuiltIn"),
+ defaultGroupName,
+ tr("group.uncategorized"),
+ TagDatabase.uncommonTagKey,
+ tr("group.uncommon")
+ ]
+ return !protectedNames.contains(tag) && tagColors[tag] != nil
+ }
+
+ private func showDropWarning() {
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
+ appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning")
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
+ withAnimation(.easeOut(duration: 0.18)) {
+ appGridInteraction.dropWarningToast = nil
+ }
+ }
+ }
+
+ private func showDropRefresh() {
+ appGridInteraction.dropRefreshStartedAt = Date()
+ withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) {
+ appGridInteraction.dropRefreshVisible = true
+ }
+ }
+
+ private func finishDropRefreshAfterMinimumDuration() {
+ let minimumDuration: TimeInterval = 0.85
+ let elapsed = appGridInteraction.dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration
+ let delay = max(0, minimumDuration - elapsed)
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
+ withAnimation(.easeOut(duration: 0.18)) {
+ appGridInteraction.dropRefreshVisible = false
+ }
+ appGridInteraction.dropRefreshStartedAt = nil
+ }
+ }
+
+ private func setAppDragMode(_ active: Bool) {
+ guard appGridInteraction.appDragModeActive != active else { return }
+ if active {
+ endTagNavReorder()
+ clearAppBubbleState()
+ }
+ appGridInteraction.appDragModeActive = active
+ if active {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
+ if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag {
+ appGridInteraction.appDragModeActive = false
+ }
+ }
+ }
+ }
+
+ private func resetTransientDragState(
+ keepingPendingUncategorizedDrop: Bool = false,
+ keepingPendingTagRemovalDrop: Bool = false
+ ) {
+ let hadAppDragState = appGridInteraction.appDragModeActive
+ AppDragCoordinator.shared.cancelDrag()
+ if appGridInteraction.appDragModeActive {
+ appGridInteraction.appDragModeActive = false
+ }
+ if hadAppDragState {
+ appGridInteraction.appDragResetToken &+= 1
+ }
+ if tagNavDragModeActive {
+ tagNavDragModeActive = false
+ }
+ if tagNavDragItem != nil {
+ tagNavDragItem = nil
+ }
+ if tagNavReorderDidMove {
+ tagNavReorderDidMove = false
+ }
+ if dragItem != nil {
+ dragItem = nil
+ }
+ clearAppBubbleState()
+ if !keepingPendingUncategorizedDrop, appGridInteraction.pendingUncategorizedDrop != nil {
+ appGridInteraction.pendingUncategorizedDrop = nil
+ }
+ if !keepingPendingTagRemovalDrop, appGridInteraction.pendingTagRemovalDrop != nil {
+ appGridInteraction.pendingTagRemovalDrop = nil
+ }
+ }
+
+ func refreshApps(forceLayoutRefresh: Bool = false) {
+ guard !refreshInProgress else {
+ if forceLayoutRefresh {
+ refreshAgainAfterCurrent = true
+ refreshAgainForceLayout = true
+ }
+ return
+ }
+
+ refreshInProgress = true
DispatchQueue.global(qos: .userInitiated).async {
- var apps = AppIndexer.scan()
- let store = TagDatabase.load()
- apps = TagEditor.annotate(apps: apps)
+ let scannedApps = AppIndexer.scan()
+ let reconciledStore = TagEditor.reconcileScannedApps(scannedApps)
+ let smartStartResult = SmartStartService.runIfNeeded(
+ apps: scannedApps,
+ store: reconciledStore
+ )
+ let store = smartStartResult.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 {
allApps = apps
+ quickSearchDocuments = quickSearchDocs
tagColors = colors
draggedTagNames = order
+ rebuildDisplayGroups(apps: apps, tagOrder: order)
+ if quickSearchVisible {
+ refreshQuickSearchResults()
+ }
+ handleSmartStartRunResult(smartStartResult)
+ if forceLayoutRefresh {
+ finishDropRefreshAfterMinimumDuration()
+ }
+ refreshInProgress = false
+ if refreshAgainAfterCurrent {
+ let shouldForceLayout = refreshAgainForceLayout
+ refreshAgainAfterCurrent = false
+ refreshAgainForceLayout = false
+ refreshApps(forceLayoutRefresh: shouldForceLayout)
+ }
+ }
+ }
+ }
+
+ private func launchApp(
+ _ app: AppInfo,
+ closeQuickSearchOnSuccess: Bool = false,
+ closeOverlayOnSuccess: Bool = true,
+ onFailure: (() -> Void)? = nil
+ ) {
+ appGridInteraction.appDragModeActive = false
+ endTagNavReorder()
+ let configuration = NSWorkspace.OpenConfiguration()
+ NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
+ DispatchQueue.main.async {
+ guard error == nil else {
+ onFailure?()
+ return
+ }
+
+ DispatchQueue.global(qos: .utility).async {
+ TagEditor.recordLauncherOpen(for: app.path.path)
+ }
+ if closeQuickSearchOnSuccess {
+ closeQuickSearch(hideOverlayIfNeeded: false)
+ }
+ if closeOverlayOnSuccess {
+ hideOverlay()
+ }
}
}
}
func openApp(_ app: AppInfo) {
hideOverlay()
- NSWorkspace.shared.open(app.path)
+ launchApp(app, closeOverlayOnSuccess: false)
}
}
// MARK: - Tag Label
-private struct TagLabel: Identifiable {
- var id: String { name }
- let name: String
- let colorIndex: Int
+private struct TagReorderFramePreferenceKey: PreferenceKey {
+ static var defaultValue: [String: CGRect] = [:]
+
+ static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
+ value.merge(nextValue(), uniquingKeysWith: { _, new in new })
+ }
+}
+
+private struct TagNavReorderFramePreferenceKey: PreferenceKey {
+ static var defaultValue: [String: CGRect] = [:]
+
+ static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
+ value.merge(nextValue(), uniquingKeysWith: { _, new in new })
+ }
}
// MARK: - NSVisualEffectView bridge
--
Gitblit v1.9.3