| | |
| | | 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 |
| | |
| | | |
| | | // 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 |
| | | |
| | |
| | | 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() |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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 |
| | | } |
| | |
| | | } |
| | | |
| | | func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { |
| | | if textView.hasMarkedText() { |
| | | return false |
| | | } |
| | | if commandSelector == #selector(NSResponder.insertNewline(_:)) { |
| | | onSubmit?() |
| | | return true |
| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | 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 |
| | | var tagNavigationAppDropTargetName: String? = nil |
| | | var tagNavigationLastHoverScrollID: String? = nil |
| | | var tagNavigationLastHoverScrollAt: Date? = nil |
| | | } |
| | | |
| | | private struct EditActionFeedback: Identifiable { |
| | |
| | | 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 |
| | |
| | | @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 |
| | |
| | | @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 { |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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 |
| | | ] |
| | | ) |
| | | } |
| | | |
| | |
| | | // 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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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") { |
| | |
| | | onActivate: { tagID in |
| | | activateTagNavigation(tagID) |
| | | }, |
| | | onDoubleActivate: { _ in |
| | | openTagSettingsFromNavigation() |
| | | }, |
| | | onHoverChange: { tagID, active in |
| | | handleTagNavigationHover(tagID, active: active) |
| | | }, |
| | |
| | | ) |
| | | } |
| | | |
| | | 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: { |
| | |
| | | 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: { |
| | |
| | | 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, |
| | |
| | | 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 |
| | | ) |
| | | } |
| | | } |
| | |
| | | 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, |
| | |
| | | } 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 |
| | |
| | | private var editAppsView: some View { |
| | | VStack(spacing: 0) { |
| | | EditAppsHeaderView( |
| | | theme: renderedAppGridTheme, |
| | | operation: editTagOperation, |
| | | hintText: editModeHintText, |
| | | confirmTitle: editConfirmTitle, |
| | |
| | | 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 |
| | | ) |
| | |
| | | .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 |
| | | } |
| | |
| | | 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) |
| | | |
| | |
| | | colorIndex: colorIndex, |
| | | operation: editTagOperation, |
| | | isSelected: isSelected, |
| | | isRemovableCandidate: isRemovableCandidate |
| | | isRemovableCandidate: isRemovableCandidate, |
| | | theme: renderedAppGridTheme |
| | | ) { |
| | | if isSelected { |
| | | selectedTagNames.remove(tagName) |
| | |
| | | .onEnded { _ in |
| | | guard canReorderTag(tagName) else { return } |
| | | dragItem = nil |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | persistTagOrderIfAllowed() |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | 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 = [] |
| | |
| | | return EditableAppSelectionItem( |
| | | app: app, |
| | | iconSize: iconSize, |
| | | isSelected: isSelected |
| | | isSelected: isSelected, |
| | | theme: renderedAppGridTheme |
| | | ) { |
| | | toggleEditableAppSelection(app) |
| | | } |
| | |
| | | } |
| | | .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 { |
| | |
| | | |
| | | 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 |
| | | } |
| | |
| | | } |
| | | |
| | | 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] { |
| | |
| | | // 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() { |
| | |
| | | |
| | | 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 |
| | | } |
| | | } |
| | |
| | | |
| | | 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) |
| | |
| | | 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 |
| | | } |
| | |
| | | |
| | | 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 } |
| | |
| | | } |
| | | |
| | | private func handleTagNavigationHover(_ id: String, active: Bool) { |
| | | if appGridInteraction.appDragModeActive { |
| | | if appGridInteraction.appDragModeActive || tagNavDragModeActive { |
| | | if !active, appGridInteraction.tagNavigationHoveredGroupName == id { |
| | | appGridInteraction.tagNavigationHoveredGroupName = nil |
| | | } |
| | |
| | | |
| | | appGridInteraction.tagNavigationHoveredGroupName = id |
| | | fillColorlessContainer(id) |
| | | scheduleTagNavigationHoverScroll(id) |
| | | } |
| | | |
| | | private func handleTagNavigationAppDropHover(_ id: String, active: Bool) { |
| | |
| | | } |
| | | |
| | | 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) { |
| | | 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) { |
| | |
| | | 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() { |
| | |
| | | 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 |
| | |
| | | } |
| | | |
| | | 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) |
| | | } |
| | |
| | | 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) |
| | | } |
| | | |
| | |
| | | |
| | | 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) |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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) |
| | | } |
| | |
| | | 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)) { |
| | |
| | | } |
| | | |
| | | refreshInProgress = true |
| | | scheduleLoadingSpinnerIfNeeded() |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | let result = AppLibraryController.refresh(useCache: useCache) |
| | | DispatchQueue.main.async { |
| | |
| | | finishDropRefreshAfterMinimumDuration() |
| | | } |
| | | refreshInProgress = false |
| | | hideLoadingSpinner() |
| | | if refreshAgainAfterCurrent { |
| | | let shouldForceLayout = refreshAgainForceLayout |
| | | let shouldUseCache = refreshAgainUseCache |
| | |
| | | } |
| | | } |
| | | |
| | | 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() |
| | |
| | | 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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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) |