From 0ae3a9359e4cdf6ae5b669c4affd91662463f370 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:17:36 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.1 candidate
---
src/Apptag/ContentView.swift | 1077 ++++++++++++++++++++++++++++++++++++++++++++++++-----------
1 files changed, 867 insertions(+), 210 deletions(-)
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index e2bf1c7..3fede8e 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -8,9 +8,19 @@
static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged")
static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange")
static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested")
+ static let tagLauncherPreferencesTabRequested = Notification.Name("TagLauncherPreferencesTabRequested")
static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow")
static let tagLauncherOverlayDidHide = Notification.Name("TagLauncherOverlayDidHide")
static let tagLauncherModalInteractionChanged = Notification.Name("TagLauncherModalInteractionChanged")
+}
+
+enum SettingsTabTarget {
+ static let userInfoKey = "tab"
+ static let theme = "theme"
+ static let tags = "tags"
+ static let data = "data"
+ static let pro = "pro"
+ static let about = "about"
}
// MARK: - Edit Phase
@@ -28,8 +38,8 @@
// MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
-/// Custom container that wraps NSTextField so that hitTest returns the container
-/// and mouseDown can reliably make the text field first responder.
+/// Custom container that wraps NSTextField so first focus is reliable while
+/// subsequent clicks keep native caret placement and selection behavior.
final class TextFieldContainer: NSView {
let textField: NSTextField
@@ -48,14 +58,33 @@
textField.frame = bounds
}
+ private var isEditing: Bool {
+ guard let window else { return false }
+ return window.firstResponder === textField || textField.currentEditor() != nil
+ }
+
override func hitTest(_ point: NSPoint) -> NSView? {
- if bounds.contains(point) { return self }
- return nil
+ guard bounds.contains(point) else { return nil }
+ if isEditing {
+ let fieldPoint = textField.convert(point, from: self)
+ return textField.hitTest(fieldPoint) ?? textField
+ }
+ return self
+ }
+
+ func focusTextField() {
+ guard let w = window else { return }
+ let shouldSelectAll = !isEditing
+ w.makeFirstResponder(textField)
+ if shouldSelectAll {
+ textField.selectText(nil)
+ }
}
override func mouseDown(with event: NSEvent) {
- if let w = window { w.makeFirstResponder(textField) }
- textField.mouseDown(with: event)
+ // Do not forward this same NSEvent to NSTextField.mouseDown. AppKit can
+ // forward it back to this container during control tracking and recurse.
+ focusTextField()
}
}
@@ -82,6 +111,10 @@
}
func updateNSView(_ container: TextFieldContainer, context: Context) {
+ if let editor = container.textField.currentEditor() as? NSTextView,
+ editor.hasMarkedText() {
+ return
+ }
if container.textField.stringValue != text {
container.textField.stringValue = text
}
@@ -106,6 +139,9 @@
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+ if textView.hasMarkedText() {
+ return false
+ }
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
onSubmit?()
return true
@@ -198,19 +234,21 @@
struct TagPill: View {
let name: String
let colorIndex: Int
+ var customColor: TagCustomColor? = nil
var dragModeActive: Bool = false
var isDragging: Bool = false
let action: () -> Void
+ private var resolvedNSColor: NSColor {
+ TagColor.nsColor(for: colorIndex, customColor: customColor)
+ }
+
private var bgColor: Color {
- Color(nsColor: TagColor.nsColor(for: colorIndex))
+ Color(nsColor: resolvedNSColor)
}
private var textColor: Color {
- if colorIndex == 0 || colorIndex == 5 {
- return .primary
- }
- return .white
+ TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
}
var body: some View {
@@ -237,15 +275,21 @@
struct SideTagPill: View {
let name: String
let colorIndex: Int
+ var customColor: TagCustomColor? = nil
var dragModeActive: Bool = false
var isDragging: Bool = false
let action: () -> Void
- private var bgColor: Color {
- Color(nsColor: TagColor.nsColor(for: colorIndex))
+ private var resolvedNSColor: NSColor {
+ TagColor.nsColor(for: colorIndex, customColor: customColor)
}
+
+ private var bgColor: Color {
+ Color(nsColor: resolvedNSColor)
+ }
+
private var textColor: Color {
- colorIndex == 0 || colorIndex == 5 ? .primary : .white
+ TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
}
var body: some View {
@@ -313,8 +357,11 @@
var editingBubble: AppBubbleContext? = nil
var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil
+ var proPrompt: PendingProPrompt? = nil
var uncategorizedDropSuppressFuturePrompt = false
var tagRemovalDropSuppressFuturePrompt = false
+ var usageTipsCloseReminderVisible = false
+ var usageTipsCloseSuppressFuturePrompt = false
var appDragResetToken = 0
var bubbleDraftNote = ""
var tagNavigationHoveredGroupName: String? = nil
@@ -342,17 +389,31 @@
let tagName: String
}
+private struct PendingProPrompt: Identifiable {
+ let id = UUID()
+ let feature: ProFeature
+ let noteQuotaStatus: ProNoteQuotaStatus?
+ let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
+}
+
struct ContentView: View {
let hideOverlay: () -> Void
private let initialQuickSearchSource: String?
@Environment(\.colorScheme) private var colorScheme
+ @ObservedObject private var proEntitlement = ProEntitlementCenter.shared
@State private var allApps: [AppInfo] = []
@State private var displayGroups: [TagGroup] = []
@State private var tagColors: [String: Int] = [:]
+ @State private var tagCustomColors: [String: TagCustomColor] = [:]
+ @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
+ @State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()
+ @State private var containerAppOrder: [String: [String]] = [:]
@State private var groupLayoutVersion = 0
@State private var appGridScrollTargetID: String? = nil
@State private var appGridScrollRequestToken = 0
+ @State private var selectedUsageTipIndex = 0
+ @State private var usageTipsHovered = false
// Edit mode
@State private var editPhase: EditPhase = .none
@@ -374,6 +435,8 @@
@State private var smartStartNotice: SmartStartNotice? = nil
@State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil
@State private var refreshInProgress = false
+ @State private var loadingSpinnerVisible = false
+ @State private var loadingSpinnerToken = 0
@State private var refreshAgainAfterCurrent = false
@State private var refreshAgainForceLayout = false
@State private var refreshAgainUseCache = true
@@ -414,30 +477,72 @@
@AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
@AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
@AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+ @AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips
+ @AppStorage(AppGridTheme.storageKey) private var appGridThemeID = AppDefaults.appGridThemeID
+ @AppStorage(AppGridTheme.deepBlueTuningStorageKey) private var deepBlueThemeTuning = AppGridTheme.deepBlue.defaultColorTuning
+ @AppStorage(AppGridTheme.colorfulTuningStorageKey) private var colorfulThemeTuning = AppGridTheme.colorful.defaultColorTuning
@AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
@AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
@AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false
+ @AppStorage("skipUsageTipsCloseReminder") private var skipUsageTipsCloseReminder = false
private let editSidebarWidth: CGFloat = 188
private let editSidebarHorizontalInset: CGFloat = 12
private let floatingControlsTrailingInset: CGFloat = 20
private let floatingControlsReservedWidth: CGFloat = 120
+ private let loadingSpinnerDelay: TimeInterval = 0.25
private var appBubbleDisabled: Bool {
- appGridInteraction.appDragModeActive
+ usageTipsHovered
+ || appGridInteraction.appDragModeActive
|| appGridInteraction.pendingUncategorizedDrop != nil
|| appGridInteraction.pendingTagRemovalDrop != nil
+ || appGridInteraction.usageTipsCloseReminderVisible
+ || appGridInteraction.proPrompt != nil
}
private var appGridHighlightedGroupName: String? {
appGridInteraction.tagNavigationHoveredGroupName
?? (isColorlessContainerMode ? filledColorlessContainer : nil)
}
private let rightSidebarFloatingClearance: CGFloat = 44
+ private let tagNavigationHoverScrollDelay: TimeInterval = 0.14
private let tagNavigationHoverScrollInterval: TimeInterval = 0.22
-
private var floatingButtonSurfaceColor: Color {
- colorScheme == .dark
+ return colorScheme == .dark
? Color.white.opacity(0.10)
: Color.white.opacity(0.78)
+ }
+
+ private var appGridTheme: AppGridTheme {
+ AppGridTheme(storedID: appGridThemeID)
+ }
+
+ private var renderedAppGridTheme: AppGridTheme {
+ editPhase == .none
+ ? ProEntitlementPolicy.effectiveTheme(for: appGridTheme)
+ : .defaultLight
+ }
+
+ private var renderedAppGridThemeTuning: Double? {
+ guard editPhase == .none else { return nil }
+ return ProEntitlementPolicy.effectiveThemeTuning(
+ for: appGridTheme,
+ storedTuning: storedThemeTuning(for: appGridTheme)
+ )
+ }
+
+ private var renderedDisplayMode: String {
+ ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)
+ }
+
+ private func storedThemeTuning(for theme: AppGridTheme) -> Double? {
+ switch theme {
+ case .deepBlue:
+ return deepBlueThemeTuning
+ case .colorful:
+ return colorfulThemeTuning
+ case .defaultLight, .black, .pink, .purple, .green, .blue:
+ return nil
+ }
}
private var isSideLayout: Bool {
@@ -449,7 +554,24 @@
}
private var isColorlessContainerMode: Bool {
- displayMode == "container" || displayMode == "gridContainer"
+ renderedDisplayMode == "container" || renderedDisplayMode == "gridContainer"
+ }
+
+ private var shouldShowUsageTips: Bool {
+ !hideUsageTips && !allApps.isEmpty && !quickSearchOnlySession
+ }
+
+ private var appGridUsageTips: [AppGridUsageTip] {
+ [
+ AppGridUsageTip(id: 1, titleKey: "usageTips.tip1.title", detailKey: "usageTips.tip1.detail"),
+ AppGridUsageTip(id: 2, titleKey: "usageTips.tip2.title", detailKey: "usageTips.tip2.detail"),
+ AppGridUsageTip(id: 3, titleKey: "usageTips.tip3.title", detailKey: "usageTips.tip3.detail"),
+ AppGridUsageTip(id: 4, titleKey: "usageTips.tip4.title", detailKey: "usageTips.tip4.detail"),
+ AppGridUsageTip(id: 5, titleKey: "usageTips.tip5.title", detailKey: "usageTips.tip5.detail"),
+ AppGridUsageTip(id: 6, titleKey: "usageTips.tip6.title", detailKey: "usageTips.tip6.detail"),
+ AppGridUsageTip(id: 7, titleKey: "usageTips.tip7.title", detailKey: "usageTips.tip7.detail"),
+ AppGridUsageTip(id: 8, titleKey: "usageTips.tip8.title", detailKey: "usageTips.tip8.detail"),
+ ]
}
private var shouldRenderAppGridBehindQuickSearch: Bool {
@@ -460,175 +582,174 @@
}
var body: some View {
+ contentStack
+ .onAppear(perform: handleContentAppear)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow), perform: handleOverlayDidShow)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide), perform: handleOverlayDidHide)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchRequested), perform: handleQuickSearchRequested)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherQuickSearchDismissRequested), perform: handleQuickSearchDismissRequested)
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification), perform: handleApplicationDidBecomeActive)
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange), perform: handleDataDidChange)
+ .onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange), perform: handleAppLanguageDidChange)
+ .onChange(of: editPhase) { _, newPhase in
+ let active = newPhase != .none
+ dismissAppBubble()
+ NotificationCenter.default.post(
+ 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()
+ }
+ .onChange(of: appGridInteraction.proPrompt != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onChange(of: smartStartNotice != nil) { _, _ in
+ publishModalInteractionState()
+ }
+ .onChange(of: proEntitlement.accessState) { _, newState in
+ handleProAccessStateChange(newState)
+ }
+ .onDisappear {
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": false]
+ )
+ }
+ }
+
+ @ViewBuilder
+ private var contentStack: some View {
ZStack {
if shouldRenderAppGridBehindQuickSearch {
- VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
- .ignoresSafeArea()
- .allowsHitTesting(false)
+ appGridBackground
}
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
+ appGridInteractionLayer
}
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)
- }
+ dropWarningToastOverlay
+ dropRefreshOverlay
}
- .onAppear {
- refreshNotchHeight()
- refreshAppsForOverlay()
- if let initialQuickSearchSource, !initialQuickSearchConsumed {
- initialQuickSearchConsumed = true
- if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
- quickSearchCloseHidesOverlay = true
- quickSearchOnlySession = true
- }
- if !quickSearchVisible {
- quickSearchVisible = true
- quickSearchFocusToken &+= 1
- }
- quickSearchOpenedAt = Date()
- refreshQuickSearchResults()
- refreshAppsForQuickSearch()
- NotificationCenter.default.post(
- name: .tagLauncherQuickSearchVisibilityChanged,
- object: nil,
- userInfo: [
- "active": true,
- "hideOverlayOnClose": quickSearchCloseHidesOverlay
- ]
+ }
+
+ @ViewBuilder
+ private var appGridInteractionLayer: some View {
+ 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
+ usageTipsCloseReminderOverlay
+ proAccessPromptOverlay
+ }
+
+ @ViewBuilder
+ private var dropWarningToastOverlay: some View {
+ 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)
+ }
+ }
+
+ @ViewBuilder
+ private var proAccessPromptOverlay: some View {
+ if let prompt = appGridInteraction.proPrompt {
+ ZStack {
+ Color.black.opacity(0.16)
+ .ignoresSafeArea()
+
+ ProUpgradePromptView(
+ title: tr(prompt.feature.titleKey),
+ message: proPromptMessage(for: prompt),
+ benefitText: tr(prompt.feature.benefitKey),
+ operationText: proEntitlement.operationState.statusMessageKey.map(tr),
+ unlockTitle: tr("pro.card.unlock"),
+ restoreTitle: proPromptSecondaryTitle(for: prompt),
+ isBusy: proEntitlement.operationState.isBusy,
+ onClose: dismissProPrompt,
+ onUnlock: { proEntitlement.purchasePro() },
+ onRestore: { handleProPromptSecondaryAction(prompt) }
)
}
+ .transition(.opacity)
+ .zIndex(920)
}
- .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
- resetTransientDragState()
- refreshNotchHeight()
- refreshAppsForOverlay()
- }
- .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
- resetTransientDragState()
- closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
- quickSearchOnlySession = false
- quickSearchCloseHidesOverlay = 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)) { notification in
- let source = notification.userInfo?["source"] as? String
- closeQuickSearch(dismissalSource: source)
- }
- .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 {
- _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
- _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
- DispatchQueue.main.async {
- refreshApps(forceLayoutRefresh: true)
- }
- }
- }
- .onChange(of: editPhase) { _, newPhase in
- let active = newPhase != .none
- dismissAppBubble()
- NotificationCenter.default.post(
- 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]
- )
+ }
+
+ @ViewBuilder
+ private var dropRefreshOverlay: some View {
+ 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)
}
}
@@ -636,7 +757,13 @@
NotificationCenter.default.post(
name: .tagLauncherModalInteractionChanged,
object: nil,
- userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil]
+ userInfo: [
+ "active": smartStartNotice != nil
+ || appGridInteraction.pendingUncategorizedDrop != nil
+ || appGridInteraction.pendingTagRemovalDrop != nil
+ || appGridInteraction.usageTipsCloseReminderVisible
+ || appGridInteraction.proPrompt != nil
+ ]
)
}
@@ -648,7 +775,10 @@
// Always sync tag list from database when entering edit mode
let store = TagDatabase.load()
tagColors = store.tags.mapValues { $0.color }
+ tagCustomColors = store.tags.compactMapValues { $0.customColor }
+ tagDefinitions = store.tags
draggedTagNames = TagEditor.orderedTagNames()
+ refreshCustomContainerQuotaStatus(in: store)
}
editPhase = phase
if phase == .none {
@@ -919,6 +1049,40 @@
}
}
+ private var appGridBackground: some View {
+ let theme = renderedAppGridTheme
+ let tuning = renderedAppGridThemeTuning
+ return ZStack {
+ if theme.usesVisualEffectBackdrop {
+ VisualEffectView(
+ material: theme.material,
+ blendingMode: .behindWindow
+ )
+ }
+ if theme.isPureBlackBackground {
+ Color.black
+ }
+ if !theme.isDefaultLight {
+ LinearGradient(
+ colors: theme.backgroundBaseColors(tuning: tuning),
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ LinearGradient(
+ colors: theme.backgroundAccentColors(tuning: tuning),
+ startPoint: .topTrailing,
+ endPoint: .bottomLeading
+ )
+ if theme.backgroundDimmingOpacity > 0 {
+ Rectangle()
+ .fill(Color.black.opacity(theme.backgroundDimmingOpacity))
+ }
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
+ }
+
private var floatingActionButtons: some View {
HStack(spacing: 8) {
floatingOverlayButton(systemImage: "pencil.line") {
@@ -1016,6 +1180,9 @@
onActivate: { tagID in
activateTagNavigation(tagID)
},
+ onDoubleActivate: { _ in
+ openTagSettingsFromNavigation()
+ },
onHoverChange: { tagID, active in
handleTagNavigationHover(tagID, active: active)
},
@@ -1043,11 +1210,20 @@
)
}
+ private func openTagSettingsFromNavigation() {
+ NotificationCenter.default.post(
+ name: .tagLauncherOpenPreferencesRequested,
+ object: nil,
+ userInfo: [SettingsTabTarget.userInfoKey: SettingsTabTarget.tags]
+ )
+ }
+
private var swiftUITopTagBar: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(tagLabels) { tag in
TagPill(name: tag.name, colorIndex: tag.colorIndex,
+ customColor: tag.customColor,
dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
isDragging: tagNavDragItem == tag.name,
action: {
@@ -1075,6 +1251,7 @@
VStack(spacing: 6) {
ForEach(tagLabels) { tag in
SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
+ customColor: tag.customColor,
dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
isDragging: tagNavDragItem == tag.name,
action: {
@@ -1104,18 +1281,26 @@
Group {
if allApps.isEmpty {
Spacer()
- ProgressView().scaleEffect(0.8)
+ if loadingSpinnerVisible {
+ ProgressView().scaleEffect(0.8)
+ }
Spacer()
} else {
AppGridCollectionView(
groups: displayGroups,
tagColors: tagColors,
- displayMode: displayMode,
+ tagCustomColors: tagCustomColors,
+ displayMode: renderedDisplayMode,
iconSize: iconSize,
showNames: !hideAppNames,
+ appGridTheme: renderedAppGridTheme,
bubbleDisabled: appBubbleDisabled,
showUncommonAppBubbles: showUncommonAppBubbles,
highlightedGroupName: appGridHighlightedGroupName,
+ bottomContentPadding: shouldShowUsageTips ? AppGridUsageTipsMetrics.reservedHeight : 0,
+ usageTipsVisible: shouldShowUsageTips,
+ usageTips: appGridUsageTips,
+ selectedUsageTipIndex: $selectedUsageTipIndex,
contentRevision: groupLayoutVersion,
scrollTargetID: appGridScrollTargetID,
scrollRequestToken: appGridScrollRequestToken,
@@ -1128,13 +1313,18 @@
onDropOutsideGroup: { path, source, copy in
dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
},
+ onReorderApps: { containerID, orderedPaths in
+ reorderApps(inContainer: containerID, orderedPaths: orderedPaths)
+ },
onGroupActivate: { groupName in
if isColorlessContainerMode {
toggleColorlessFill(groupName)
}
},
onScrollActivity: handleAppGridScrollActivity,
- onDragModeChange: { setAppDragMode($0) }
+ onDragModeChange: { setAppDragMode($0) },
+ onHideUsageTips: requestHideUsageTips,
+ onUsageTipsHoverChange: handleUsageTipsHoverChange
)
}
}
@@ -1162,6 +1352,8 @@
isEditing: editing,
placement: placement,
arrowOffset: metrics.arrowOffset,
+ noteQuotaText: editing ? noteQuotaHintText(for: context.app) : nil,
+ noteQuotaWarning: editing && isNoteQuotaWarning(for: context.app),
draftNote: $appGridInteraction.bubbleDraftNote,
noteFocused: $bubbleNoteFocused,
onCommit: commitBubbleNote,
@@ -1196,24 +1388,80 @@
} label: {
Label(tr("edit.exit"), systemImage: "rectangle.portrait.and.arrow.right")
.font(.system(size: 12))
+ .foregroundStyle(renderedAppGridTheme.editPrimaryTextColor)
+ .padding(.horizontal, 8)
+ .frame(height: 28)
+ .background(
+ RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .fill(renderedAppGridTheme.editControlSurfaceColor)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 7, style: .continuous)
+ .stroke(renderedAppGridTheme.editControlStrokeColor, lineWidth: 1)
+ )
}
- .buttonStyle(.bordered)
+ .buttonStyle(.plain)
+ .shadow(color: renderedAppGridTheme.editButtonShadowColor, radius: 10, y: 4)
Spacer()
- Text(tr("edit.tags")).font(.headline)
+ Text(tr("edit.tags"))
+ .font(.headline)
+ .foregroundStyle(renderedAppGridTheme.editPrimaryTextColor)
Spacer()
}
.padding(.horizontal, 24)
.padding(.top, notchHeight > 0 ? notchHeight + 10 : 20)
.padding(.bottom, 12)
+ .background(renderedAppGridTheme.editToolbarSurfaceColor)
- Divider().opacity(0.3)
+ Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
TagEditorView(
tagColors: $tagColors,
+ tagCustomColors: $tagCustomColors,
excludedTagNames: ["Mac自带", defaultGroupName],
- onRefresh: { refreshApps() }
+ isCustomColorUnlocked: proEntitlement.isUnlocked,
+ onLockedCustomColor: { presentProPrompt(for: .customTagColors) },
+ onCustomContainerQuotaExceeded: presentCustomContainerQuotaPrompt,
+ onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus,
+ topLeadingAccessory: AnyView(customContainerQuotaAccessory),
+ onRefresh: {
+ refreshCustomContainerQuotaStatus()
+ refreshApps()
+ }
)
}
+ }
+
+ private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
+ if proEntitlement.isUnlocked {
+ return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
+ }
+ return customContainerQuotaStatus
+ }
+
+ private var customContainerQuotaAccessory: some View {
+ let status = effectiveCustomContainerQuotaStatus
+ let text = status.isUnlimited
+ ? tr("pro.customContainers.quotaUnlimitedShort")
+ : tr(
+ "pro.customContainers.quotaShort",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)"
+ ]
+ )
+ return Text(text)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ private func refreshCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
+ customContainerQuotaStatus = status
+ }
+
+ private func refreshCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
+ customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
}
// MARK: - Edit Apps View
@@ -1221,6 +1469,7 @@
private var editAppsView: some View {
VStack(spacing: 0) {
EditAppsHeaderView(
+ theme: renderedAppGridTheme,
operation: editTagOperation,
hintText: editModeHintText,
confirmTitle: editConfirmTitle,
@@ -1234,11 +1483,12 @@
onConfirm: confirmAssign
)
- Divider().opacity(0.3)
+ Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
EditAppsSidebarIntroView(
+ theme: renderedAppGridTheme,
width: editSidebarWidth,
horizontalInset: editSidebarHorizontalInset
)
@@ -1270,10 +1520,14 @@
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
.frame(width: editSidebarWidth, alignment: .topLeading)
- Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1)
+ Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(width: 1)
if allApps.isEmpty {
- Spacer(); ProgressView().scaleEffect(0.8); Spacer()
+ Spacer()
+ if loadingSpinnerVisible {
+ ProgressView().scaleEffect(0.8)
+ }
+ Spacer()
} else {
editAppsGrid
}
@@ -1297,12 +1551,12 @@
private func editFlatGroup(_ group: TagGroup) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
+ Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
Text(group.name)
.font(.system(size: tagFontSize, weight: .semibold))
- .foregroundStyle(.secondary)
+ .foregroundStyle(renderedAppGridTheme.editSecondaryTextColor)
.padding(.horizontal, 10)
- Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
+ Rectangle().fill(renderedAppGridTheme.editDividerColor).frame(height: 1)
}
.padding(.bottom, 6)
@@ -1335,7 +1589,8 @@
colorIndex: colorIndex,
operation: editTagOperation,
isSelected: isSelected,
- isRemovableCandidate: isRemovableCandidate
+ isRemovableCandidate: isRemovableCandidate,
+ theme: renderedAppGridTheme
) {
if isSelected {
selectedTagNames.remove(tagName)
@@ -1358,7 +1613,7 @@
.onEnded { _ in
guard canReorderTag(tagName) else { return }
dragItem = nil
- TagEditor.reorderTags(draggedTagNames)
+ persistTagOrderIfAllowed()
}
}
@@ -1386,9 +1641,11 @@
switch editTagOperation {
case .add:
- TagEditor.appendTags(tags, to: paths)
+ let result = TagEditor.appendTags(tags, to: paths)
+ guard handleTagMutationResult(result) else { return }
case .remove:
- TagEditor.removeTags(tags, from: paths)
+ let result = TagEditor.removeTags(tags, from: paths)
+ guard handleTagMutationResult(result) else { return }
}
selectedAppPaths = []
selectedTagNames = []
@@ -1409,7 +1666,8 @@
return EditableAppSelectionItem(
app: app,
iconSize: iconSize,
- isSelected: isSelected
+ isSelected: isSelected,
+ theme: renderedAppGridTheme
) {
toggleEditableAppSelection(app)
}
@@ -1557,6 +1815,34 @@
}
.ignoresSafeArea()
.allowsHitTesting(appGridInteraction.pendingTagRemovalDrop != nil)
+ }
+
+ private var usageTipsCloseReminderOverlay: some View {
+ GeometryReader { proxy in
+ if appGridInteraction.usageTipsCloseReminderVisible {
+ ZStack {
+ Color.black.opacity(0.14)
+ .ignoresSafeArea()
+
+ UsageTipsCloseReminderBubble(
+ title: tr("usageTips.closeReminder.title"),
+ message: tr("usageTips.closeReminder.message"),
+ previewCallout: tr("usageTips.closeReminder.previewCallout"),
+ doNotRemindTitle: tr("usageTips.closeReminder.doNotRemind"),
+ doNotRemind: $appGridInteraction.usageTipsCloseSuppressFuturePrompt,
+ confirmTitle: tr("edit.confirm"),
+ onCancel: dismissUsageTipsCloseReminder,
+ onConfirm: confirmUsageTipsCloseReminder
+ )
+ .frame(width: min(760, max(420, proxy.size.width - 120)))
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ }
+ .zIndex(712)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(appGridInteraction.usageTipsCloseReminderVisible)
}
private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
@@ -1775,13 +2061,27 @@
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)
+ let rawGroups = AppIndexer.group(
+ apps: apps,
+ defaultGroupName: defaultGroupName,
+ tagOrder: order,
+ tagDefinitions: tagDefinitions,
+ containerAppOrder: containerAppOrder
+ )
return rawGroups.map { group in
if group.name == defaultGroupName {
- return TagGroup(name: tr("group.uncategorized"), apps: group.apps)
+ return TagGroup(
+ containerID: group.containerID,
+ name: tr("group.uncategorized"),
+ apps: group.apps
+ )
}
if group.name == "Mac自带" {
- return TagGroup(name: tr("group.appleBuiltIn"), apps: group.apps)
+ return TagGroup(
+ containerID: group.containerID,
+ name: tr("group.appleBuiltIn"),
+ apps: group.apps
+ )
}
return group
}
@@ -1793,7 +2093,13 @@
}
private var tagLabels: [TagNavigationItem] {
- displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
+ displayGroups.map {
+ TagNavigationItem(
+ name: $0.name,
+ colorIndex: tagColors[$0.name] ?? 0,
+ customColor: tagCustomColors[$0.name]
+ )
+ }
}
private var editGroups: [TagGroup] {
@@ -1818,16 +2124,24 @@
// Each overlay show creates a fresh ContentView with empty `allApps`. The indexer
// cache may still be warm from a prior overlay/settings scan, so path-signature
// alone must not skip the first hydrate or the grid spinner never clears.
- guard allApps.isEmpty || AppIndexer.shouldRefreshForSearchPathChanges() else { return }
+ let wasEmpty = allApps.isEmpty
+ hydrateFromLastAppLibrarySnapshotIfNeeded()
+ guard wasEmpty || AppIndexer.shouldRefreshForSearchPathChanges() else { return }
refreshApps()
}
private func refreshAppsForQuickSearch() {
+ hydrateFromLastAppLibrarySnapshotIfNeeded()
guard allApps.isEmpty
|| quickSearchDocuments.isEmpty
|| AppIndexer.shouldRefreshForSearchPathChanges()
else { return }
refreshApps()
+ }
+
+ private func hydrateFromLastAppLibrarySnapshotIfNeeded() {
+ guard allApps.isEmpty, let snapshot = AppLibraryController.lastSnapshot() else { return }
+ applyAppLibrarySnapshot(snapshot)
}
private func refreshNotchHeight() {
@@ -1840,6 +2154,13 @@
private func handleAppGridScrollActivity() {
if appGridInteraction.hoveredBubble != nil {
+ appGridInteraction.hoveredBubble = nil
+ }
+ }
+
+ private func handleUsageTipsHoverChange(_ hovering: Bool) {
+ usageTipsHovered = hovering
+ if hovering, appGridInteraction.hoveredBubble != nil {
appGridInteraction.hoveredBubble = nil
}
}
@@ -1879,10 +2200,18 @@
private func commitBubbleNote() {
guard let context = appGridInteraction.editingBubble else { return }
+ guard appGridInteraction.proPrompt == nil else { return }
let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
.trimmingCharacters(in: .whitespacesAndNewlines)
- TagEditor.setAppNote(limited, for: context.app.path.path)
+ let decision = TagEditor.setAppNote(limited, for: context.app.path.path)
appGridInteraction.bubbleDraftNote = limited
+ guard case .allow = decision else {
+ if case .blocked(let status) = decision {
+ presentProPrompt(for: .unlimitedNotes, noteQuotaStatus: status)
+ }
+ bubbleNoteFocused = false
+ return
+ }
appGridInteraction.editingBubble = nil
bubbleNoteFocused = false
notifyAppNoteEditing(active: false)
@@ -1926,6 +2255,215 @@
return app.note ?? ""
}
+ private func noteQuotaStatus(for app: AppInfo) -> ProNoteQuotaStatus? {
+ guard !proEntitlement.isUnlocked else { return nil }
+ let decision = ProEntitlementPolicy.noteSaveDecision(
+ note: appGridInteraction.bubbleDraftNote,
+ for: app.path.path
+ )
+ switch decision {
+ case .allow(let status), .blocked(let status):
+ return status
+ }
+ }
+
+ private func noteQuotaHintText(for app: AppInfo) -> String? {
+ guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return nil }
+ if status.remaining <= 0 {
+ return tr("pro.notes.limitReached")
+ }
+ return tr(
+ "pro.notes.remaining",
+ replacements: [
+ "%remaining%": "\(status.remaining)",
+ "%limit%": "\(status.limit)"
+ ]
+ )
+ }
+
+ private func isNoteQuotaWarning(for app: AppInfo) -> Bool {
+ guard let status = noteQuotaStatus(for: app), !status.isUnlimited else { return false }
+ return status.remaining <= 0
+ }
+
+ private func proPromptMessage(for prompt: PendingProPrompt) -> String {
+ if prompt.feature == .unlimitedNotes,
+ let status = prompt.noteQuotaStatus,
+ !status.isUnlimited,
+ status.remaining <= 0 {
+ return tr("pro.notes.limitReached")
+ }
+ if prompt.feature == .customContainerQuota,
+ let status = prompt.customContainerQuotaStatus,
+ !status.isUnlimited {
+ return tr(
+ "pro.customContainers.quotaPrompt",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)",
+ "%remaining%": "\(status.remaining)"
+ ]
+ )
+ }
+ return tr(prompt.feature.promptMessageKey)
+ }
+
+ private func proPromptSecondaryTitle(for prompt: PendingProPrompt) -> String {
+ prompt.feature == .customContainerQuota
+ ? tr("pro.customContainers.manageExisting")
+ : tr("pro.card.restore")
+ }
+
+ private func handleProPromptSecondaryAction(_ prompt: PendingProPrompt) {
+ if prompt.feature == .customContainerQuota {
+ dismissProPrompt()
+ setEditPhase(.editingTags)
+ return
+ }
+ proEntitlement.restorePurchases()
+ }
+
+ private func presentProPrompt(
+ for feature: ProFeature,
+ noteQuotaStatus: ProNoteQuotaStatus? = nil,
+ customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
+ ) {
+ proEntitlement.clearTransientOperationState()
+ withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
+ appGridInteraction.proPrompt = PendingProPrompt(
+ feature: feature,
+ noteQuotaStatus: noteQuotaStatus,
+ customContainerQuotaStatus: customContainerQuotaStatus
+ )
+ }
+ }
+
+ private func presentCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
+ presentProPrompt(
+ for: .customContainerQuota,
+ customContainerQuotaStatus: status
+ )
+ }
+
+ private func handleTagMutationResult(_ result: TagEditorMutationResult) -> Bool {
+ switch result {
+ case .saved:
+ refreshCustomContainerQuotaStatus()
+ return true
+ case .noChange:
+ return false
+ case .blockedCustomContainerQuota(let status):
+ presentCustomContainerQuotaPrompt(status)
+ return false
+ }
+ }
+
+ private func dismissProPrompt() {
+ proEntitlement.clearTransientOperationState()
+ withAnimation(.easeOut(duration: 0.18)) {
+ appGridInteraction.proPrompt = nil
+ }
+ }
+
+ private func handleProAccessStateChange(_ newState: ProAccessState) {
+ guard newState.isUnlocked else { return }
+
+ if let preview = proEntitlement.themePreviewState {
+ appGridThemeID = preview.theme.rawValue
+ proEntitlement.stopThemePreview()
+ }
+ if let preview = proEntitlement.displayModePreviewState {
+ displayMode = preview.displayMode
+ proEntitlement.stopDisplayModePreview()
+ }
+
+ let hadPrompt = appGridInteraction.proPrompt != nil
+ if hadPrompt {
+ dismissProPrompt()
+ }
+
+ if appGridInteraction.editingBubble != nil {
+ DispatchQueue.main.async {
+ bubbleNoteFocused = true
+ }
+ }
+ }
+
+ private func handleContentAppear() {
+ refreshNotchHeight()
+ refreshAppsForOverlay()
+ guard let initialQuickSearchSource, !initialQuickSearchConsumed else { return }
+
+ initialQuickSearchConsumed = true
+ if initialQuickSearchSource == QuickSearchOpenSource.globalHidden {
+ quickSearchCloseHidesOverlay = true
+ quickSearchOnlySession = true
+ }
+ if !quickSearchVisible {
+ quickSearchVisible = true
+ quickSearchFocusToken &+= 1
+ }
+ quickSearchOpenedAt = Date()
+ refreshQuickSearchResults()
+ refreshAppsForQuickSearch()
+ NotificationCenter.default.post(
+ name: .tagLauncherQuickSearchVisibilityChanged,
+ object: nil,
+ userInfo: [
+ "active": true,
+ "hideOverlayOnClose": quickSearchCloseHidesOverlay
+ ]
+ )
+ }
+
+ private func handleOverlayDidShow(_ notification: Notification) {
+ _ = notification
+ resetTransientDragState()
+ refreshNotchHeight()
+ refreshAppsForOverlay()
+ }
+
+ private func handleOverlayDidHide(_ notification: Notification) {
+ _ = notification
+ resetTransientDragState()
+ closeQuickSearch(notify: true, hideOverlayIfNeeded: false)
+ quickSearchOnlySession = false
+ quickSearchCloseHidesOverlay = false
+ }
+
+ private func handleQuickSearchRequested(_ notification: Notification) {
+ let source = notification.userInfo?["source"] as? String ?? QuickSearchOpenSource.mainOverlay
+ openQuickSearch(source: source)
+ }
+
+ private func handleQuickSearchDismissRequested(_ notification: Notification) {
+ let source = notification.userInfo?["source"] as? String
+ closeQuickSearch(dismissalSource: source)
+ }
+
+ private func handleApplicationDidBecomeActive(_ notification: Notification) {
+ _ = notification
+ guard allApps.isEmpty, !refreshInProgress else { return }
+ refreshApps()
+ }
+
+ private func handleDataDidChange(_ notification: Notification) {
+ _ = notification
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func handleAppLanguageDidChange(_ notification: Notification) {
+ _ = notification
+ let appsSnapshot = allApps
+ DispatchQueue.global(qos: .userInitiated).async {
+ _ = AppleDefaultAppCatalog.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
+ _ = SmartStartService.relocalizeDefaultNotesForCurrentLanguage(apps: appsSnapshot)
+ DispatchQueue.main.async {
+ refreshApps(forceLayoutRefresh: true)
+ }
+ }
+ }
+
private func bubblePlacement(for frame: CGRect, rootFrame: CGRect) -> BubblePlacement {
frame.minY < 170 ? .below : .above
}
@@ -1961,7 +2499,8 @@
private func estimatedBubbleHeight(for app: AppInfo, width: CGFloat, isEditing: Bool) -> CGFloat {
if isEditing {
- return 120
+ let hint = noteQuotaHintText(for: app)
+ return hint == nil ? 124 : 148
}
let note = currentNote(for: app).trimmingCharacters(in: .whitespacesAndNewlines)
guard !note.isEmpty else { return 68 }
@@ -1985,7 +2524,7 @@
}
private func handleTagNavigationHover(_ id: String, active: Bool) {
- if appGridInteraction.appDragModeActive {
+ if appGridInteraction.appDragModeActive || tagNavDragModeActive {
if !active, appGridInteraction.tagNavigationHoveredGroupName == id {
appGridInteraction.tagNavigationHoveredGroupName = nil
}
@@ -2001,7 +2540,7 @@
appGridInteraction.tagNavigationHoveredGroupName = id
fillColorlessContainer(id)
- scrollToTagFromHover(id)
+ scheduleTagNavigationHoverScroll(id)
}
private func handleTagNavigationAppDropHover(_ id: String, active: Bool) {
@@ -2020,6 +2559,14 @@
}
appGridInteraction.tagNavigationAppDropTargetName = id
+ }
+
+ private func scheduleTagNavigationHoverScroll(_ id: String) {
+ DispatchQueue.main.asyncAfter(deadline: .now() + tagNavigationHoverScrollDelay) {
+ guard appGridInteraction.tagNavigationHoveredGroupName == id else { return }
+ guard !appGridInteraction.appDragModeActive && !tagNavDragModeActive else { return }
+ scrollToTagFromHover(id)
+ }
}
private func scrollToTagFromHover(_ id: String) {
@@ -2097,11 +2644,22 @@
let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
guard hadDragState else { return }
if tagNavDragModeActive && tagNavReorderDidMove {
- TagEditor.reorderTags(draggedTagNames)
+ persistTagOrderIfAllowed()
}
tagNavDragModeActive = false
tagNavDragItem = nil
tagNavReorderDidMove = false
+ }
+
+ private func persistTagOrderIfAllowed() {
+ guard proEntitlement.isUnlocked else {
+ showDropWarning(message: tr("pro.tagSorting.previewToast"))
+ return
+ }
+ guard TagEditor.reorderTags(draggedTagNames) else {
+ showDropWarning(message: tr("pro.tagSorting.previewToast"))
+ return
+ }
}
private func cancelTagNavReorderVisualState() {
@@ -2136,6 +2694,8 @@
private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
resetTransientDragState(keepingPendingUncategorizedDrop: true)
+ guard sourceTag != targetTag else { return }
+
if isUncategorizedDropTarget(targetTag) {
confirmAndMoveAppToUncategorized(path: path)
return
@@ -2147,14 +2707,14 @@
}
guard tagColors[targetTag] != nil else { return }
- guard sourceTag != targetTag || copy else { return }
- TagEditor.moveApp(
+ let result = TagEditor.moveApp(
path: path,
from: sourceTag,
to: targetTag,
color: tagColors[targetTag] ?? 0,
copy: copy
)
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2164,8 +2724,25 @@
guard canAssignDroppedAppToTag(targetTag) else { return }
guard let app = allApps.first(where: { $0.path.path == path }) else { return }
guard !appHasTag(app, tagName: targetTag) else { return }
- TagEditor.appendTags([targetTag], to: [path])
+ let result = TagEditor.appendTags([targetTag], to: [path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
+ resetTransientDragState()
+ let key = TagDatabase.normalizedContainerID(containerID, tags: tagDefinitions)
+ let paths = TagDatabase.normalizedAppOrderPaths(orderedPaths)
+ guard !key.isEmpty, !paths.isEmpty else { return }
+
+ containerAppOrder[key] = paths
+ rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+ guard proEntitlement.isUnlocked else {
+ showDropWarning(message: tr("pro.sorting.previewToast"))
+ return
+ }
+ TagEditor.reorderApps(inContainer: key, orderedPaths: paths)
refreshApps(forceLayoutRefresh: true)
}
@@ -2285,7 +2862,8 @@
private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
guard !tags.isEmpty else { return }
- TagEditor.removeTags(tags, from: [path])
+ let result = TagEditor.removeTags(tags, from: [path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2314,11 +2892,46 @@
}
}
+ private func requestHideUsageTips() {
+ guard !hideUsageTips else { return }
+ guard !skipUsageTipsCloseReminder else {
+ hideUsageTips = true
+ return
+ }
+ clearAppBubbleState()
+ appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
+ appGridInteraction.usageTipsCloseReminderVisible = true
+ }
+ publishModalInteractionState()
+ }
+
+ private func dismissUsageTipsCloseReminder() {
+ appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
+ withAnimation(.easeOut(duration: 0.18)) {
+ appGridInteraction.usageTipsCloseReminderVisible = false
+ }
+ publishModalInteractionState()
+ }
+
+ private func confirmUsageTipsCloseReminder() {
+ if appGridInteraction.usageTipsCloseSuppressFuturePrompt {
+ skipUsageTipsCloseReminder = true
+ }
+ appGridInteraction.usageTipsCloseSuppressFuturePrompt = false
+ withAnimation(.easeOut(duration: 0.16)) {
+ appGridInteraction.usageTipsCloseReminderVisible = false
+ }
+ hideUsageTips = true
+ publishModalInteractionState()
+ }
+
private func removeTagFromDroppedApp(app: AppInfo, tagName: String) {
guard isRemovableRegularTag(tagName),
appHasTag(app, tagName: tagName)
else { return }
- TagEditor.removeTags([tagName], from: [app.path.path])
+ let result = TagEditor.removeTags([tagName], from: [app.path.path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2351,9 +2964,9 @@
return !protectedNames.contains(tag) && tagColors[tag] != nil
}
- private func showDropWarning() {
+ private func showDropWarning(message: String = tr("drop.systemDefaultWarning")) {
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
- appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning")
+ appGridInteraction.dropWarningToast = message
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) {
withAnimation(.easeOut(duration: 0.18)) {
@@ -2447,6 +3060,7 @@
}
refreshInProgress = true
+ scheduleLoadingSpinnerIfNeeded()
DispatchQueue.global(qos: .userInitiated).async {
let result = AppLibraryController.refresh(useCache: useCache)
DispatchQueue.main.async {
@@ -2456,6 +3070,7 @@
finishDropRefreshAfterMinimumDuration()
}
refreshInProgress = false
+ hideLoadingSpinner()
if refreshAgainAfterCurrent {
let shouldForceLayout = refreshAgainForceLayout
let shouldUseCache = refreshAgainUseCache
@@ -2468,11 +3083,34 @@
}
}
+ private func scheduleLoadingSpinnerIfNeeded() {
+ loadingSpinnerToken &+= 1
+ let token = loadingSpinnerToken
+ loadingSpinnerVisible = false
+ guard allApps.isEmpty else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + loadingSpinnerDelay) {
+ guard token == loadingSpinnerToken,
+ refreshInProgress,
+ allApps.isEmpty
+ else { return }
+ loadingSpinnerVisible = true
+ }
+ }
+
+ private func hideLoadingSpinner() {
+ loadingSpinnerToken &+= 1
+ loadingSpinnerVisible = false
+ }
+
private func applyAppLibrarySnapshot(_ snapshot: AppLibrarySnapshot) {
allApps = snapshot.apps
quickSearchDocuments = snapshot.quickSearchDocuments
tagColors = snapshot.tagColors
+ tagDefinitions = snapshot.tagDefinitions
+ tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }
+ containerAppOrder = snapshot.containerAppOrder
draggedTagNames = snapshot.tagOrder
+ refreshCustomContainerQuotaStatus()
rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
if quickSearchVisible {
refreshQuickSearchResults()
@@ -2501,6 +3139,15 @@
closeQuickSearch(hideOverlayIfNeeded: true)
hideOverlay()
}
+ if isCurrentApp(app) {
+ if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+ closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess)
+ }
+ if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening {
+ hideOverlay()
+ }
+ return
+ }
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in
DispatchQueue.main.async {
@@ -2522,6 +3169,16 @@
}
}
+ private func isCurrentApp(_ app: AppInfo) -> Bool {
+ if app.bundleIdentifier?.caseInsensitiveCompare(AppIdentity.bundleIdentifier) == .orderedSame {
+ return true
+ }
+
+ let currentURL = Bundle.main.bundleURL.standardizedFileURL.resolvingSymlinksInPath()
+ let appURL = app.path.standardizedFileURL.resolvingSymlinksInPath()
+ return currentURL == appURL
+ }
+
func openApp(_ app: AppInfo) {
hideOverlay()
launchApp(app, closeOverlayOnSuccess: false)
--
Gitblit v1.9.3