| | |
| | | let arrowOffset: CGFloat |
| | | } |
| | | |
| | | private struct AppGridInteractionState { |
| | | var appDragModeActive = false |
| | | var dropWarningToast: String? = nil |
| | | var dropRefreshVisible = false |
| | | var dropRefreshStartedAt: Date? = nil |
| | | var hoveredBubble: AppBubbleContext? = nil |
| | | var editingBubble: AppBubbleContext? = nil |
| | | var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil |
| | | var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil |
| | | var uncategorizedDropSuppressFuturePrompt = false |
| | | var tagRemovalDropSuppressFuturePrompt = 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 id = UUID() |
| | | let title: String |
| | |
| | | let id = UUID() |
| | | let app: AppInfo |
| | | let tagName: String |
| | | } |
| | | |
| | | private enum SmartStartNoticeMode { |
| | | case autoApplied |
| | | case suggestionOnly |
| | | case manuallyApplied |
| | | } |
| | | |
| | | private struct SmartStartNotice: Identifiable { |
| | | let id = UUID() |
| | | let mode: SmartStartNoticeMode |
| | | let title: String |
| | | let message: String |
| | | let summary: SmartStartSummary |
| | | } |
| | | |
| | | struct ContentView: View { |
| | |
| | | @State private var tagNavReorderDidMove = false |
| | | // Fixed interaction for "Colorless Container": hover fills persistently; click clears. |
| | | @State private var filledColorlessContainer: String? = nil |
| | | @State private var appDragModeActive = false |
| | | @State private var dropWarningToast: String? = nil |
| | | @State private var appGridInteraction = AppGridInteractionState() |
| | | @State private var smartStartNotice: SmartStartNotice? = nil |
| | | @State private var pendingSmartStartDraft: SmartCategorizationDraft? = nil |
| | | @State private var dropRefreshVisible = false |
| | | @State private var dropRefreshStartedAt: Date? = nil |
| | | @State private var refreshInProgress = false |
| | | @State private var refreshAgainAfterCurrent = false |
| | | @State private var refreshAgainForceLayout = false |
| | | @State private var hoveredBubble: AppBubbleContext? = nil |
| | | @State private var editingBubble: AppBubbleContext? = nil |
| | | @State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil |
| | | @State private var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil |
| | | @State private var tagRemovalDropSuppressFuturePrompt = false |
| | | @State private var appDragResetToken = 0 |
| | | @State private var bubbleDraftNote = "" |
| | | @State private var refreshAgainUseCache = true |
| | | @FocusState private var bubbleNoteFocused: Bool |
| | | |
| | | // Quick Search |
| | |
| | | @State private var quickSearchFocusToken = 0 |
| | | @State private var quickSearchSelectionScrollToken = 0 |
| | | @State private var quickSearchCloseHidesOverlay = false |
| | | @State private var quickSearchOnlySession = false |
| | | @State private var quickSearchOpenedAt: Date? = nil |
| | | @State private var quickSearchErrorMessage: String? = nil |
| | | @State private var initialQuickSearchConsumed = false |
| | | |
| | |
| | | self.hideOverlay = hideOverlay |
| | | self.initialQuickSearchSource = initialQuickSearchSource |
| | | let startsInQuickSearch = initialQuickSearchSource != nil |
| | | let startsInQuickSearchOnlySession = initialQuickSearchSource == QuickSearchOpenSource.globalHidden |
| | | _quickSearchVisible = State(initialValue: startsInQuickSearch) |
| | | _quickSearchCloseHidesOverlay = State(initialValue: initialQuickSearchSource == QuickSearchOpenSource.globalHidden) |
| | | _quickSearchCloseHidesOverlay = State(initialValue: startsInQuickSearchOnlySession) |
| | | _quickSearchOnlySession = State(initialValue: startsInQuickSearchOnlySession) |
| | | _quickSearchFocusToken = State(initialValue: startsInQuickSearch ? 1 : 0) |
| | | } |
| | | |
| | |
| | | @AppStorage("displayMode") private var displayMode = AppDefaults.displayMode |
| | | @AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames |
| | | @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles |
| | | @AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation |
| | | @AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false |
| | | @AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false |
| | | |
| | | private let editSidebarWidth: CGFloat = 188 |
| | | private let editSidebarHorizontalInset: CGFloat = 12 |
| | | private let floatingControlsTrailingInset: CGFloat = 20 |
| | | private let floatingControlsReservedWidth: CGFloat = 120 |
| | | private var appBubbleDisabled: Bool { |
| | | appDragModeActive || pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil |
| | | appGridInteraction.appDragModeActive |
| | | || appGridInteraction.pendingUncategorizedDrop != nil |
| | | || appGridInteraction.pendingTagRemovalDrop != nil |
| | | } |
| | | private var appGridHighlightedGroupName: String? { |
| | | appGridInteraction.tagNavigationHoveredGroupName |
| | | ?? (isColorlessContainerMode ? filledColorlessContainer : nil) |
| | | } |
| | | private let rightSidebarFloatingClearance: CGFloat = 44 |
| | | private let tagNavigationHoverScrollInterval: TimeInterval = 0.22 |
| | | |
| | | private var floatingButtonSurfaceColor: Color { |
| | | colorScheme == .dark |
| | |
| | | } |
| | | |
| | | private var shouldRenderAppGridBehindQuickSearch: Bool { |
| | | !quickSearchVisible || !quickSearchCloseHidesOverlay |
| | | if quickSearchOnlySession { |
| | | return false |
| | | } |
| | | return !quickSearchVisible || !quickSearchCloseHidesOverlay |
| | | } |
| | | |
| | | var body: some View { |
| | |
| | | |
| | | quickSearchOverlay |
| | | |
| | | if shouldRenderAppGridBehindQuickSearch, let message = dropWarningToast { |
| | | if shouldRenderAppGridBehindQuickSearch, let message = appGridInteraction.dropWarningToast { |
| | | Text(message) |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | |
| | | .allowsHitTesting(false) |
| | | } |
| | | |
| | | if shouldRenderAppGridBehindQuickSearch && dropRefreshVisible { |
| | | if shouldRenderAppGridBehindQuickSearch && appGridInteraction.dropRefreshVisible { |
| | | Color.black.opacity(0.08) |
| | | .ignoresSafeArea() |
| | | .transition(.opacity) |
| | |
| | | } |
| | | .onAppear { |
| | | refreshNotchHeight() |
| | | refreshAppsIfNeeded() |
| | | refreshAppsForOverlay() |
| | | if let initialQuickSearchSource, !initialQuickSearchConsumed { |
| | | initialQuickSearchConsumed = true |
| | | quickSearchCloseHidesOverlay = initialQuickSearchSource == QuickSearchOpenSource.globalHidden |
| | | 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] |
| | | userInfo: [ |
| | | "active": true, |
| | | "hideOverlayOnClose": quickSearchCloseHidesOverlay |
| | | ] |
| | | ) |
| | | } |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in |
| | | resetTransientDragState() |
| | | refreshNotchHeight() |
| | | refreshAppsIfNeeded() |
| | | 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)) { _ in |
| | | closeQuickSearch() |
| | | .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 } |
| | |
| | | ) |
| | | } |
| | | .onChange(of: bubbleNoteFocused) { _, focused in |
| | | if editingBubble != nil && !focused { |
| | | if appGridInteraction.editingBubble != nil && !focused { |
| | | commitBubbleNote() |
| | | } |
| | | } |
| | |
| | | quickSearchErrorMessage = nil |
| | | refreshQuickSearchResults() |
| | | } |
| | | .onChange(of: pendingUncategorizedDrop != nil) { _, _ in |
| | | .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in |
| | | publishModalInteractionState() |
| | | } |
| | | .onChange(of: pendingTagRemovalDrop != nil) { _, _ in |
| | | .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in |
| | | publishModalInteractionState() |
| | | } |
| | | .onDisappear { |
| | |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | userInfo: ["active": pendingUncategorizedDrop != nil || pendingTagRemovalDrop != nil] |
| | | userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil] |
| | | ) |
| | | } |
| | | |
| | |
| | | .ignoresSafeArea() |
| | | .contentShape(Rectangle()) |
| | | .onTapGesture { |
| | | closeQuickSearch() |
| | | closeQuickSearch(dismissalSource: QuickSearchDismissSource.backdrop) |
| | | } |
| | | .zIndex(899) |
| | | |
| | | QuickSearchOverlayView( |
| | | QuickSearchPanelPresentationView( |
| | | query: $quickSearchQuery, |
| | | results: quickSearchResults, |
| | | selectedID: quickSearchSelectedID, |
| | | focusToken: quickSearchFocusToken, |
| | | selectionScrollToken: quickSearchSelectionScrollToken, |
| | | isLoading: quickSearchDocuments.isEmpty && refreshInProgress, |
| | | isLoading: quickSearchShouldShowLoading, |
| | | maxVisibleRows: quickSearchMaxVisibleRows(in: proxy.size), |
| | | panelTopY: quickSearchPanelTopY(in: proxy.size), |
| | | panelHeight: quickSearchPanelContentHeight(in: proxy.size), |
| | | errorMessage: quickSearchErrorMessage, |
| | | onCommand: handleQuickSearchCommand, |
| | | onHover: selectQuickSearchResult, |
| | | onLaunch: launchQuickSearchResult |
| | | ) |
| | | .position( |
| | | x: proxy.size.width / 2, |
| | | y: quickSearchPanelCenterY(in: proxy.size) |
| | | ) |
| | | .transition(.scale(scale: 0.98).combined(with: .opacity)) |
| | | .frame(width: 0, height: 0) |
| | | .allowsHitTesting(false) |
| | | .zIndex(900) |
| | | } |
| | | } |
| | |
| | | .animation(.easeOut(duration: 0.12), value: quickSearchVisible) |
| | | } |
| | | |
| | | private func quickSearchPanelCenterY(in size: CGSize) -> CGFloat { |
| | | let visibleRows = max(1, min(quickSearchResults.isEmpty ? 1 : quickSearchResults.count, quickSearchMaxVisibleRows(in: size))) |
| | | let estimatedPanelHeight = CGFloat(visibleRows) * 76 + 122 |
| | | return quickSearchPanelTopY(in: size) + estimatedPanelHeight / 2 |
| | | private func quickSearchPanelContentHeight(in size: CGSize) -> CGFloat { |
| | | let hasResultList = !quickSearchShouldShowLoading |
| | | && quickSearchErrorMessage == nil |
| | | && !quickSearchResults.isEmpty |
| | | let visibleRows = min(max(1, quickSearchResults.count), quickSearchMaxVisibleRows(in: size)) |
| | | return QuickSearchPanelMetrics.contentHeight( |
| | | hasResultList: hasResultList, |
| | | visibleRows: visibleRows |
| | | ) |
| | | } |
| | | |
| | | private func quickSearchPanelTopY(in size: CGSize) -> CGFloat { |
| | |
| | | |
| | | private func quickSearchMaxVisibleRows(in size: CGSize) -> Int { |
| | | let bottomClearance: CGFloat = 84 |
| | | let chromeHeight: CGFloat = 122 |
| | | let rowHeightWithSpacing: CGFloat = 76 |
| | | let chromeHeight = QuickSearchPanelMetrics.headerHeight |
| | | + QuickSearchPanelMetrics.dividerHeight |
| | | + QuickSearchPanelMetrics.resultListVerticalInset * 2 |
| | | let rowHeightWithSpacing = QuickSearchPanelMetrics.rowHeight |
| | | + QuickSearchPanelMetrics.rowSpacing |
| | | let availableHeight = max(0, size.height - quickSearchPanelTopY(in: size) - bottomClearance - chromeHeight) |
| | | return max(1, min(8, Int(floor(availableHeight / rowHeightWithSpacing)))) |
| | | } |
| | |
| | | private func openQuickSearch(source: String) { |
| | | guard canOpenQuickSearch else { return } |
| | | dismissAppBubble() |
| | | quickSearchCloseHidesOverlay = source == QuickSearchOpenSource.globalHidden |
| | | if source == QuickSearchOpenSource.globalHidden { |
| | | quickSearchOnlySession = true |
| | | } |
| | | quickSearchCloseHidesOverlay = quickSearchCloseHidesOverlay |
| | | || source == QuickSearchOpenSource.globalHidden |
| | | quickSearchVisible = true |
| | | quickSearchOpenedAt = Date() |
| | | quickSearchQuery = "" |
| | | quickSearchManualSelection = false |
| | | quickSearchErrorMessage = nil |
| | | quickSearchFocusToken &+= 1 |
| | | refreshQuickSearchResults() |
| | | refreshAppsForQuickSearch() |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchVisibilityChanged, |
| | | object: nil, |
| | | userInfo: ["active": true] |
| | | userInfo: [ |
| | | "active": true, |
| | | "hideOverlayOnClose": quickSearchCloseHidesOverlay |
| | | ] |
| | | ) |
| | | } |
| | | |
| | | private var canOpenQuickSearch: Bool { |
| | | editPhase == .none |
| | | && pendingUncategorizedDrop == nil |
| | | && appGridInteraction.pendingUncategorizedDrop == nil |
| | | && smartStartNotice == nil |
| | | && !dropRefreshVisible |
| | | && !appGridInteraction.dropRefreshVisible |
| | | && !quickSearchVisible |
| | | } |
| | | |
| | | private func closeQuickSearch(notify: Bool = true, hideOverlayIfNeeded: Bool = true) { |
| | | private var quickSearchShouldShowLoading: Bool { |
| | | quickSearchVisible && refreshInProgress && quickSearchResults.isEmpty |
| | | } |
| | | |
| | | private func closeQuickSearch( |
| | | notify: Bool = true, |
| | | hideOverlayIfNeeded: Bool = true, |
| | | dismissalSource: String? = nil |
| | | ) { |
| | | Diagnostics.log("content.quickSearch.closeRequest", [ |
| | | "source": dismissalSource, |
| | | "notify": notify, |
| | | "hideOverlayIfNeeded": hideOverlayIfNeeded, |
| | | "quickSearchVisible": quickSearchVisible, |
| | | "quickSearchCloseHidesOverlay": quickSearchCloseHidesOverlay, |
| | | "quickSearchOnlySession": quickSearchOnlySession |
| | | ]) |
| | | if shouldIgnoreEarlyQuickSearchDismiss(from: dismissalSource) { |
| | | return |
| | | } |
| | | let shouldHideOverlay = quickSearchVisible && quickSearchCloseHidesOverlay && hideOverlayIfNeeded |
| | | guard quickSearchVisible else { return } |
| | | quickSearchVisible = false |
| | | quickSearchOpenedAt = nil |
| | | quickSearchQuery = "" |
| | | quickSearchResults = [] |
| | | quickSearchSelectedID = nil |
| | | quickSearchManualSelection = false |
| | | quickSearchErrorMessage = nil |
| | | quickSearchCloseHidesOverlay = false |
| | | if notify { |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchVisibilityChanged, |
| | | object: nil, |
| | | userInfo: ["active": false] |
| | | userInfo: [ |
| | | "active": false, |
| | | "hideOverlayOnClose": shouldHideOverlay |
| | | ] |
| | | ) |
| | | } |
| | | if shouldHideOverlay { |
| | | hideOverlay() |
| | | } else { |
| | | quickSearchCloseHidesOverlay = false |
| | | } |
| | | } |
| | | |
| | | private func shouldIgnoreEarlyQuickSearchDismiss(from source: String?) -> Bool { |
| | | guard quickSearchVisible, |
| | | let quickSearchOpenedAt, |
| | | source == QuickSearchDismissSource.mouse || source == QuickSearchDismissSource.backdrop |
| | | else { return false } |
| | | |
| | | let age = Date().timeIntervalSince(quickSearchOpenedAt) |
| | | let gracePeriod: TimeInterval = quickSearchOnlySession ? 1.25 : 0.30 |
| | | guard age < gracePeriod else { return false } |
| | | Diagnostics.log("content.quickSearch.ignoredEarlyDismiss", [ |
| | | "source": source, |
| | | "age": String(format: "%.3f", age), |
| | | "gracePeriod": String(format: "%.2f", gracePeriod) |
| | | ]) |
| | | return true |
| | | } |
| | | |
| | | private func refreshQuickSearchResults() { |
| | | let previousSelection = quickSearchSelectedID |
| | | quickSearchResults = QuickSearchEngine.search(quickSearchQuery, documents: quickSearchDocuments) |
| | | quickSearchResults = QuickSearchEngine.search( |
| | | quickSearchQuery, |
| | | documents: quickSearchDocuments.filter { FileManager.default.fileExists(atPath: $0.app.path.path) } |
| | | ) |
| | | |
| | | if quickSearchResults.isEmpty { |
| | | quickSearchSelectedID = nil |
| | |
| | | } |
| | | |
| | | private func handleQuickSearchCommand(_ command: QuickSearchCommand) { |
| | | Diagnostics.log("content.quickSearch.command", [ |
| | | "command": String(describing: command), |
| | | "quickSearchCloseHidesOverlay": quickSearchCloseHidesOverlay, |
| | | "quickSearchOnlySession": quickSearchOnlySession |
| | | ]) |
| | | switch command { |
| | | case .moveUp: |
| | | moveQuickSearchSelection(by: -1) |
| | |
| | | private var topLayout: some View { |
| | | VStack(spacing: 0) { |
| | | Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28) |
| | | if !tagLabels.isEmpty { tagBar.padding(.bottom, 8) } |
| | | if !tagLabels.isEmpty { topTagNavigation.padding(.bottom, 8) } |
| | | Divider().opacity(0.3) |
| | | appGridContent |
| | | } |
| | |
| | | Spacer().frame(height: notchHeight > 0 ? notchHeight + 14 : 28) |
| | | Divider().opacity(0.3) |
| | | HStack(spacing: 0) { |
| | | if tagPosition == "left" { tagSidebar; sideDivider } |
| | | if tagPosition == "left" { leftTagSidebar; sideDivider } |
| | | appGridContent |
| | | if tagPosition == "right" { sideDivider; rightTagSidebar } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private var tagBar: some View { |
| | | private var topTagNavigation: some View { |
| | | Group { |
| | | if useAppKitTagNavigation { |
| | | appKitTagNavigation(orientation: .horizontal) |
| | | .frame(height: 34) |
| | | } else { |
| | | swiftUITopTagBar |
| | | } |
| | | } |
| | | } |
| | | |
| | | private var leftTagSidebar: some View { |
| | | Group { |
| | | if useAppKitTagNavigation { |
| | | appKitTagNavigation(orientation: .vertical) |
| | | } else { |
| | | swiftUITagSidebarList |
| | | } |
| | | } |
| | | .frame(width: 135) |
| | | } |
| | | |
| | | private var rightTagSidebar: some View { |
| | | Group { |
| | | if useAppKitTagNavigation { |
| | | appKitTagNavigation(orientation: .vertical) |
| | | } else { |
| | | swiftUITagSidebarList |
| | | } |
| | | } |
| | | .padding(.top, rightSidebarFloatingClearance) |
| | | .frame(width: 135) |
| | | } |
| | | |
| | | private func appKitTagNavigation(orientation: TagNavigationView.Orientation) -> some View { |
| | | let isHorizontal = orientation == .horizontal |
| | | let appDropTargetID = appGridInteraction.tagNavigationAppDropTargetName |
| | | return TagNavigationView( |
| | | items: tagLabels, |
| | | orientation: orientation, |
| | | contentInsets: isHorizontal |
| | | ? NSEdgeInsets(top: 3, left: 24, bottom: 3, right: tagPosition == "top" ? floatingControlsReservedWidth : 24) |
| | | : NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12), |
| | | dragModeActive: tagNavDragModeActive, |
| | | draggingItemID: tagNavDragItem, |
| | | appDragModeActive: appGridInteraction.appDragModeActive, |
| | | appDropTargetID: appDropTargetID, |
| | | onActivate: { tagID in |
| | | activateTagNavigation(tagID) |
| | | }, |
| | | onHoverChange: { tagID, active in |
| | | handleTagNavigationHover(tagID, active: active) |
| | | }, |
| | | canReorder: { tagID in |
| | | canReorderTag(tagID) |
| | | }, |
| | | canAcceptAppDrop: { tagID in |
| | | canAssignDroppedAppToTag(tagID) |
| | | }, |
| | | onReorderBegan: { tagID in |
| | | beginTagNavReorder(tagID) |
| | | }, |
| | | onReorderMoved: { tagID, targetID in |
| | | reorderTagNavItem(fromName: tagID, to: targetID) |
| | | }, |
| | | onReorderEnded: { |
| | | endTagNavReorder() |
| | | }, |
| | | onAppDropHoverChange: { tagID, active in |
| | | handleTagNavigationAppDropHover(tagID, active: active) |
| | | }, |
| | | onAppDrop: { path, source, targetTag, copy in |
| | | dropAppOnTagNavigation(path: path, sourceTag: source, targetTag: targetTag, copy: copy) |
| | | } |
| | | ) |
| | | } |
| | | |
| | | private var swiftUITopTagBar: some View { |
| | | ScrollView(.horizontal, showsIndicators: false) { |
| | | HStack(spacing: 8) { |
| | | ForEach(tagLabels) { tag in |
| | |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | | .highPriorityGesture(tagNavReorderGesture(for: tag.name)) |
| | | .onHover { hovering in |
| | | if hovering { |
| | | fillColorlessContainer(tag.id) |
| | | scrollTo(tag.id) |
| | | } |
| | | handleTagNavigationHover(tag.id, active: hovering) |
| | | } |
| | | } |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | private var tagSidebar: some View { |
| | | tagSidebarList |
| | | .frame(width: 135) |
| | | } |
| | | |
| | | private var rightTagSidebar: some View { |
| | | tagSidebarList |
| | | .padding(.top, rightSidebarFloatingClearance) |
| | | .frame(width: 135) |
| | | } |
| | | |
| | | private var tagSidebarList: some View { |
| | | private var swiftUITagSidebarList: some View { |
| | | ScrollView(.vertical, showsIndicators: false) { |
| | | VStack(spacing: 6) { |
| | | ForEach(tagLabels) { tag in |
| | |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | | .highPriorityGesture(tagNavReorderGesture(for: tag.name)) |
| | | .onHover { hovering in |
| | | if hovering { |
| | | fillColorlessContainer(tag.id) |
| | | scrollTo(tag.id) |
| | | } |
| | | handleTagNavigationHover(tag.id, active: hovering) |
| | | } |
| | | } |
| | | } |
| | |
| | | showNames: !hideAppNames, |
| | | bubbleDisabled: appBubbleDisabled, |
| | | showUncommonAppBubbles: showUncommonAppBubbles, |
| | | highlightedGroupName: filledColorlessContainer, |
| | | highlightedGroupName: appGridHighlightedGroupName, |
| | | contentRevision: groupLayoutVersion, |
| | | scrollTargetID: appGridScrollTargetID, |
| | | scrollRequestToken: appGridScrollRequestToken, |
| | |
| | | |
| | | private var uncommonAppBubbleOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let context = editingBubble ?? hoveredBubble { |
| | | if let context = appGridInteraction.editingBubble ?? appGridInteraction.hoveredBubble { |
| | | let rootFrame = proxy.frame(in: .global) |
| | | let editing = editingBubble != nil |
| | | let editing = appGridInteraction.editingBubble != nil |
| | | let width = min(editing ? 440 : 520, max(260, proxy.size.width - 48)) |
| | | let placement = bubblePlacement(for: context.frame, rootFrame: rootFrame) |
| | | let metrics = bubbleMetrics( |
| | |
| | | isEditing: editing, |
| | | placement: placement, |
| | | arrowOffset: metrics.arrowOffset, |
| | | draftNote: $bubbleDraftNote, |
| | | draftNote: $appGridInteraction.bubbleDraftNote, |
| | | noteFocused: $bubbleNoteFocused, |
| | | onCommit: commitBubbleNote, |
| | | onCancel: dismissAppBubble |
| | |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(editingBubble != nil) |
| | | .allowsHitTesting(appGridInteraction.editingBubble != nil) |
| | | } |
| | | |
| | | private var smartStartNoticeOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let notice = smartStartNotice { |
| | | let panelWidth = min(560, max(320, proxy.size.width - 64)) |
| | | |
| | | ZStack { |
| | | Color.black.opacity(0.10) |
| | | .ignoresSafeArea() |
| | | |
| | | VStack(spacing: 18) { |
| | | Image(systemName: notice.mode == .suggestionOnly ? "sparkles" : "checkmark.seal.fill") |
| | | .font(.system(size: 30, weight: .semibold)) |
| | | .foregroundStyle(Color.accentColor) |
| | | .frame(width: 58, height: 58) |
| | | .background( |
| | | Circle() |
| | | .fill(Color.accentColor.opacity(0.12)) |
| | | ) |
| | | |
| | | VStack(spacing: 8) { |
| | | Text(notice.title) |
| | | .font(.system(size: 21, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .multilineTextAlignment(.center) |
| | | |
| | | Text(notice.message) |
| | | .font(.system(size: 15, weight: .regular)) |
| | | .foregroundStyle(.secondary) |
| | | .multilineTextAlignment(.center) |
| | | .lineLimit(4) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | | } |
| | | |
| | | HStack(spacing: 12) { |
| | | switch notice.mode { |
| | | case .suggestionOnly: |
| | | Button(tr("smartstart.later")) { |
| | | dismissSmartStartNotice() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.large) |
| | | |
| | | Button(tr("smartstart.apply")) { |
| | | applySmartStartSuggestion() |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.large) |
| | | case .autoApplied, .manuallyApplied: |
| | | if notice.summary.backupPath != nil { |
| | | Button(tr("smartstart.undo")) { |
| | | undoSmartStart() |
| | | } |
| | | .buttonStyle(.bordered) |
| | | .controlSize(.large) |
| | | } |
| | | Button(tr("smartstart.ok")) { |
| | | dismissSmartStartNotice() |
| | | } |
| | | .buttonStyle(.borderedProminent) |
| | | .controlSize(.large) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 30) |
| | | .padding(.vertical, 26) |
| | | .frame(width: panelWidth) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 16) |
| | | .fill(.ultraThickMaterial) |
| | | .shadow(color: .black.opacity(0.26), radius: 28, y: 16) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 16) |
| | | .stroke(.white.opacity(0.16), lineWidth: 1) |
| | | ) |
| | | } |
| | | .frame(width: proxy.size.width, height: proxy.size.height) |
| | | .transition(.scale(scale: 0.96).combined(with: .opacity)) |
| | | .zIndex(650) |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(smartStartNotice != nil) |
| | | SmartStartNoticeOverlay( |
| | | notice: smartStartNotice, |
| | | onDismiss: dismissSmartStartNotice, |
| | | onApply: applySmartStartSuggestion, |
| | | onUndo: undoSmartStart |
| | | ) |
| | | } |
| | | // MARK: - Edit Tags View |
| | | |
| | |
| | | |
| | | private var uncategorizedDropConfirmOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let pendingDrop = pendingUncategorizedDrop { |
| | | if let pendingDrop = appGridInteraction.pendingUncategorizedDrop { |
| | | ZStack { |
| | | Color.black.opacity(0.14) |
| | | .ignoresSafeArea() |
| | |
| | | UncategorizedDropConfirmBubble( |
| | | title: tr("drop.uncategorizedConfirmTitle"), |
| | | message: uncategorizedConfirmMessage(for: pendingDrop), |
| | | doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"), |
| | | doNotRemind: $appGridInteraction.uncategorizedDropSuppressFuturePrompt, |
| | | cancelTitle: tr("tag.cancel"), |
| | | confirmTitle: tr("edit.confirm"), |
| | | onCancel: dismissUncategorizedDropConfirm, |
| | |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(pendingUncategorizedDrop != nil) |
| | | .allowsHitTesting(appGridInteraction.pendingUncategorizedDrop != nil) |
| | | } |
| | | |
| | | private var tagRemovalDropConfirmOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let pendingDrop = pendingTagRemovalDrop { |
| | | if let pendingDrop = appGridInteraction.pendingTagRemovalDrop { |
| | | ZStack { |
| | | Color.black.opacity(0.14) |
| | | .ignoresSafeArea() |
| | |
| | | title: tr("drop.removeTagConfirmTitle"), |
| | | message: tagRemovalConfirmMessage(for: pendingDrop), |
| | | doNotRemindTitle: tr("drop.removeTagDoNotAskAgain"), |
| | | doNotRemind: $tagRemovalDropSuppressFuturePrompt, |
| | | doNotRemind: $appGridInteraction.tagRemovalDropSuppressFuturePrompt, |
| | | cancelTitle: tr("drop.removeTagConfirmNo"), |
| | | confirmTitle: tr("drop.removeTagConfirmYes"), |
| | | onCancel: dismissTagRemovalDropConfirm, |
| | |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(pendingTagRemovalDrop != nil) |
| | | .allowsHitTesting(appGridInteraction.pendingTagRemovalDrop != nil) |
| | | } |
| | | |
| | | private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback { |
| | |
| | | |
| | | let scannedApps = allApps |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | let result = SmartStartService.applySuggestion(draft) |
| | | let store = result.store |
| | | let apps = TagEditor.annotate(apps: scannedApps, store: store) |
| | | let quickSearchDocs = QuickSearchEngine.makeDocuments(apps: apps, store: store) |
| | | let colors = store.tags.mapValues { $0.color } |
| | | let order = TagEditor.orderedTagNames() |
| | | let result = AppLibraryController.applySmartStartSuggestion( |
| | | draft, |
| | | scannedApps: scannedApps |
| | | ) |
| | | |
| | | DispatchQueue.main.async { |
| | | pendingSmartStartDraft = nil |
| | | allApps = apps |
| | | quickSearchDocuments = quickSearchDocs |
| | | tagColors = colors |
| | | draggedTagNames = order |
| | | rebuildDisplayGroups(apps: apps, tagOrder: order) |
| | | if quickSearchVisible { |
| | | refreshQuickSearchResults() |
| | | } |
| | | applyAppLibrarySnapshot(result.snapshot) |
| | | if let summary = result.summary { |
| | | showSmartStartNotice(mode: .manuallyApplied, summary: summary) |
| | | } else { |
| | |
| | | groupLayoutVersion &+= 1 |
| | | } |
| | | |
| | | private var tagLabels: [TagLabel] { |
| | | displayGroups.map { TagLabel(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) } |
| | | private var tagLabels: [TagNavigationItem] { |
| | | displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) } |
| | | } |
| | | |
| | | private var editGroups: [TagGroup] { |
| | |
| | | refreshApps() |
| | | } |
| | | |
| | | private func refreshAppsForOverlay() { |
| | | guard AppIndexer.shouldRefreshForSearchPathChanges() else { return } |
| | | refreshApps() |
| | | } |
| | | |
| | | private func refreshAppsForQuickSearch() { |
| | | guard AppIndexer.shouldRefreshForSearchPathChanges() else { return } |
| | | refreshApps() |
| | | } |
| | | |
| | | private func refreshNotchHeight() { |
| | | let mousePoint = NSEvent.mouseLocation |
| | | let activeScreen = NSScreen.screens.first(where: { |
| | |
| | | } |
| | | |
| | | private func handleAppGridScrollActivity() { |
| | | hoveredBubble = nil |
| | | if appGridInteraction.hoveredBubble != nil { |
| | | appGridInteraction.hoveredBubble = nil |
| | | } |
| | | } |
| | | |
| | | private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) { |
| | |
| | | clearAppBubbleState() |
| | | return |
| | | } |
| | | guard editingBubble == nil else { return } |
| | | guard appGridInteraction.editingBubble == nil else { return } |
| | | switch event { |
| | | case .entered(let canShowBubble): |
| | | if canShowBubble { |
| | | hoveredBubble = AppBubbleContext(app: app, frame: frame) |
| | | appGridInteraction.hoveredBubble = AppBubbleContext(app: app, frame: frame) |
| | | } else { |
| | | hoveredBubble = nil |
| | | appGridInteraction.hoveredBubble = nil |
| | | } |
| | | case .exited: |
| | | guard hoveredBubble?.app.path == app.path else { return } |
| | | hoveredBubble = nil |
| | | guard appGridInteraction.hoveredBubble?.app.path == app.path else { return } |
| | | appGridInteraction.hoveredBubble = nil |
| | | } |
| | | } |
| | | |
| | |
| | | clearAppBubbleState() |
| | | return |
| | | } |
| | | bubbleDraftNote = currentNote(for: app) |
| | | hoveredBubble = nil |
| | | editingBubble = AppBubbleContext(app: app, frame: frame) |
| | | appGridInteraction.bubbleDraftNote = currentNote(for: app) |
| | | appGridInteraction.hoveredBubble = nil |
| | | appGridInteraction.editingBubble = AppBubbleContext(app: app, frame: frame) |
| | | notifyAppNoteEditing(active: true) |
| | | DispatchQueue.main.async { |
| | | bubbleNoteFocused = true |
| | |
| | | } |
| | | |
| | | private func commitBubbleNote() { |
| | | guard let context = editingBubble else { return } |
| | | let limited = String(bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength)) |
| | | guard let context = appGridInteraction.editingBubble else { return } |
| | | let limited = String(appGridInteraction.bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength)) |
| | | .trimmingCharacters(in: .whitespacesAndNewlines) |
| | | TagEditor.setAppNote(limited, for: context.app.path.path) |
| | | bubbleDraftNote = limited |
| | | editingBubble = nil |
| | | appGridInteraction.bubbleDraftNote = limited |
| | | appGridInteraction.editingBubble = nil |
| | | bubbleNoteFocused = false |
| | | notifyAppNoteEditing(active: false) |
| | | refreshApps() |
| | | } |
| | | |
| | | private func dismissAppBubble() { |
| | | hoveredBubble = nil |
| | | if editingBubble != nil { |
| | | appGridInteraction.hoveredBubble = nil |
| | | if appGridInteraction.editingBubble != nil { |
| | | notifyAppNoteEditing(active: false) |
| | | } |
| | | editingBubble = nil |
| | | appGridInteraction.editingBubble = nil |
| | | bubbleNoteFocused = false |
| | | } |
| | | |
| | | private func clearAppBubbleState() { |
| | | if hoveredBubble != nil { |
| | | hoveredBubble = nil |
| | | if appGridInteraction.hoveredBubble != nil { |
| | | appGridInteraction.hoveredBubble = nil |
| | | } |
| | | if editingBubble != nil { |
| | | if appGridInteraction.editingBubble != nil { |
| | | notifyAppNoteEditing(active: false) |
| | | editingBubble = nil |
| | | appGridInteraction.editingBubble = nil |
| | | } |
| | | if bubbleNoteFocused { |
| | | bubbleNoteFocused = false |
| | |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(id) |
| | | } |
| | | scrollTo(id) |
| | | } |
| | | |
| | | private func handleTagNavigationHover(_ id: String, active: Bool) { |
| | | if appGridInteraction.appDragModeActive { |
| | | if !active, appGridInteraction.tagNavigationHoveredGroupName == id { |
| | | appGridInteraction.tagNavigationHoveredGroupName = nil |
| | | } |
| | | return |
| | | } |
| | | |
| | | guard active else { |
| | | if appGridInteraction.tagNavigationHoveredGroupName == id { |
| | | appGridInteraction.tagNavigationHoveredGroupName = nil |
| | | } |
| | | return |
| | | } |
| | | |
| | | appGridInteraction.tagNavigationHoveredGroupName = id |
| | | fillColorlessContainer(id) |
| | | scrollToTagFromHover(id) |
| | | } |
| | | |
| | | private func handleTagNavigationAppDropHover(_ id: String, active: Bool) { |
| | | guard appGridInteraction.appDragModeActive else { |
| | | if appGridInteraction.tagNavigationAppDropTargetName == id { |
| | | appGridInteraction.tagNavigationAppDropTargetName = nil |
| | | } |
| | | return |
| | | } |
| | | |
| | | guard active, canAssignDroppedAppToTag(id) else { |
| | | if appGridInteraction.tagNavigationAppDropTargetName == id { |
| | | appGridInteraction.tagNavigationAppDropTargetName = nil |
| | | } |
| | | return |
| | | } |
| | | |
| | | appGridInteraction.tagNavigationAppDropTargetName = 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 reorderTagNavItem(at location: CGPoint) { |
| | | guard let fromName = tagNavDragItem, |
| | | let targetName = tagNavReorderFrames.first(where: { $0.value.contains(location) })?.key, |
| | | fromName != targetName, |
| | | fromName != targetName |
| | | else { return } |
| | | reorderTagNavItem(fromName: fromName, to: targetName) |
| | | } |
| | | |
| | | private func reorderTagNavItem(fromName: String, to targetName: String) { |
| | | guard fromName != targetName, |
| | | canReorderTag(fromName), |
| | | canReorderTag(targetName), |
| | | let fromIndex = draggedTagNames.firstIndex(of: fromName), |
| | | let toIndex = draggedTagNames.firstIndex(of: targetName) |
| | | else { return } |
| | | |
| | | tagNavReorderDidMove = true |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) { |
| | | let destination = toIndex > fromIndex ? toIndex + 1 : toIndex |
| | |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func dropAppOnTagNavigation(path: String, sourceTag: String, targetTag: String, copy: Bool) { |
| | | resetTransientDragState() |
| | | 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]) |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func canAssignDroppedAppToTag(_ tagName: String) -> Bool { |
| | | tagColors[tagName] != nil |
| | | && !isAppleBuiltInDropTarget(tagName) |
| | | && !isUncategorizedDropTarget(tagName) |
| | | } |
| | | |
| | | private func dropAppOutsideGroup(path: String, sourceTag: String, copy: Bool) { |
| | | resetTransientDragState(keepingPendingTagRemovalDrop: true) |
| | | guard isRemovableRegularTag(sourceTag) else { return } |
| | |
| | | } |
| | | |
| | | clearAppBubbleState() |
| | | tagRemovalDropSuppressFuturePrompt = false |
| | | appGridInteraction.tagRemovalDropSuppressFuturePrompt = false |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) { |
| | | pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag) |
| | | appGridInteraction.pendingTagRemovalDrop = PendingTagRemovalDrop(app: app, tagName: sourceTag) |
| | | } |
| | | } |
| | | |
| | |
| | | guard !removableTags.isEmpty else { return } |
| | | let assignedTags = assignedRegularDisplayTags(for: removableTags) |
| | | |
| | | if skipUncategorizedDropConfirm { |
| | | moveDroppedAppToUncategorized(path: app.path.path, tags: removableTags) |
| | | return |
| | | } |
| | | |
| | | clearAppBubbleState() |
| | | appGridInteraction.uncategorizedDropSuppressFuturePrompt = false |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) { |
| | | pendingUncategorizedDrop = PendingUncategorizedDrop( |
| | | appGridInteraction.pendingUncategorizedDrop = PendingUncategorizedDrop( |
| | | app: app, |
| | | assignedTags: assignedTags, |
| | | removableTags: removableTags |
| | |
| | | } |
| | | |
| | | private func dismissUncategorizedDropConfirm() { |
| | | appGridInteraction.uncategorizedDropSuppressFuturePrompt = false |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | pendingUncategorizedDrop = nil |
| | | appGridInteraction.pendingUncategorizedDrop = nil |
| | | } |
| | | } |
| | | |
| | | private func confirmPendingUncategorizedDrop() { |
| | | guard let pendingDrop = pendingUncategorizedDrop else { return } |
| | | guard let pendingDrop = appGridInteraction.pendingUncategorizedDrop else { return } |
| | | let path = pendingDrop.app.path.path |
| | | let tags = pendingDrop.removableTags |
| | | let shouldSuppressFuturePrompt = appGridInteraction.uncategorizedDropSuppressFuturePrompt |
| | | if shouldSuppressFuturePrompt { |
| | | skipUncategorizedDropConfirm = true |
| | | } |
| | | appGridInteraction.uncategorizedDropSuppressFuturePrompt = false |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | pendingUncategorizedDrop = nil |
| | | appGridInteraction.pendingUncategorizedDrop = nil |
| | | } |
| | | moveDroppedAppToUncategorized(path: path, tags: tags) |
| | | } |
| | | |
| | | private func moveDroppedAppToUncategorized(path: String, tags: [String]) { |
| | | guard !tags.isEmpty else { return } |
| | | TagEditor.removeTags(tags, from: [path]) |
| | | showDropRefresh() |
| | |
| | | } |
| | | |
| | | private func dismissTagRemovalDropConfirm() { |
| | | tagRemovalDropSuppressFuturePrompt = false |
| | | appGridInteraction.tagRemovalDropSuppressFuturePrompt = false |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | pendingTagRemovalDrop = nil |
| | | appGridInteraction.pendingTagRemovalDrop = nil |
| | | } |
| | | } |
| | | |
| | | private func confirmPendingTagRemovalDrop() { |
| | | guard let pendingDrop = pendingTagRemovalDrop else { return } |
| | | guard let pendingDrop = appGridInteraction.pendingTagRemovalDrop else { return } |
| | | let app = pendingDrop.app |
| | | let tagName = pendingDrop.tagName |
| | | let shouldSuppressFuturePrompt = tagRemovalDropSuppressFuturePrompt |
| | | let shouldSuppressFuturePrompt = appGridInteraction.tagRemovalDropSuppressFuturePrompt |
| | | if shouldSuppressFuturePrompt { |
| | | skipTagRemovalDropConfirm = true |
| | | } |
| | | tagRemovalDropSuppressFuturePrompt = false |
| | | appGridInteraction.tagRemovalDropSuppressFuturePrompt = false |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | pendingTagRemovalDrop = nil |
| | | appGridInteraction.pendingTagRemovalDrop = nil |
| | | } |
| | | DispatchQueue.main.async { |
| | | removeTagFromDroppedApp(app: app, tagName: tagName) |
| | |
| | | |
| | | private func showDropWarning() { |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { |
| | | dropWarningToast = tr("drop.systemDefaultWarning") |
| | | appGridInteraction.dropWarningToast = tr("drop.systemDefaultWarning") |
| | | } |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | dropWarningToast = nil |
| | | appGridInteraction.dropWarningToast = nil |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func showDropRefresh() { |
| | | dropRefreshStartedAt = Date() |
| | | appGridInteraction.dropRefreshStartedAt = Date() |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) { |
| | | dropRefreshVisible = true |
| | | appGridInteraction.dropRefreshVisible = true |
| | | } |
| | | } |
| | | |
| | | private func finishDropRefreshAfterMinimumDuration() { |
| | | let minimumDuration: TimeInterval = 0.85 |
| | | let elapsed = dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration |
| | | let elapsed = appGridInteraction.dropRefreshStartedAt.map { Date().timeIntervalSince($0) } ?? minimumDuration |
| | | let delay = max(0, minimumDuration - elapsed) |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | dropRefreshVisible = false |
| | | appGridInteraction.dropRefreshVisible = false |
| | | } |
| | | dropRefreshStartedAt = nil |
| | | appGridInteraction.dropRefreshStartedAt = nil |
| | | } |
| | | } |
| | | |
| | | private func setAppDragMode(_ active: Bool) { |
| | | guard appDragModeActive != active else { return } |
| | | guard appGridInteraction.appDragModeActive != active else { return } |
| | | if active { |
| | | endTagNavReorder() |
| | | clearAppBubbleState() |
| | | appGridInteraction.tagNavigationAppDropTargetName = nil |
| | | } |
| | | appDragModeActive = active |
| | | appGridInteraction.appDragModeActive = active |
| | | if !active { |
| | | appGridInteraction.tagNavigationAppDropTargetName = nil |
| | | } |
| | | if active { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 8) { |
| | | if appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag { |
| | | appDragModeActive = false |
| | | if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag { |
| | | appGridInteraction.appDragModeActive = false |
| | | appGridInteraction.tagNavigationAppDropTargetName = nil |
| | | } |
| | | } |
| | | } |
| | |
| | | keepingPendingUncategorizedDrop: Bool = false, |
| | | keepingPendingTagRemovalDrop: Bool = false |
| | | ) { |
| | | let hadAppDragState = appDragModeActive |
| | | let hadAppDragState = appGridInteraction.appDragModeActive |
| | | AppDragCoordinator.shared.cancelDrag() |
| | | if appDragModeActive { |
| | | appDragModeActive = false |
| | | if appGridInteraction.appDragModeActive { |
| | | appGridInteraction.appDragModeActive = false |
| | | } |
| | | if appGridInteraction.tagNavigationAppDropTargetName != nil { |
| | | appGridInteraction.tagNavigationAppDropTargetName = nil |
| | | } |
| | | if hadAppDragState { |
| | | appDragResetToken &+= 1 |
| | | appGridInteraction.appDragResetToken &+= 1 |
| | | } |
| | | if tagNavDragModeActive { |
| | | tagNavDragModeActive = false |
| | |
| | | dragItem = nil |
| | | } |
| | | clearAppBubbleState() |
| | | if !keepingPendingUncategorizedDrop, pendingUncategorizedDrop != nil { |
| | | pendingUncategorizedDrop = nil |
| | | if !keepingPendingUncategorizedDrop, appGridInteraction.pendingUncategorizedDrop != nil { |
| | | appGridInteraction.pendingUncategorizedDrop = nil |
| | | } |
| | | if !keepingPendingTagRemovalDrop, pendingTagRemovalDrop != nil { |
| | | pendingTagRemovalDrop = nil |
| | | if !keepingPendingTagRemovalDrop, appGridInteraction.pendingTagRemovalDrop != nil { |
| | | appGridInteraction.pendingTagRemovalDrop = nil |
| | | } |
| | | } |
| | | |
| | | func refreshApps(forceLayoutRefresh: Bool = false) { |
| | | func refreshApps(forceLayoutRefresh: Bool = false, useCache: Bool = true) { |
| | | guard !refreshInProgress else { |
| | | if forceLayoutRefresh { |
| | | refreshAgainAfterCurrent = true |
| | | refreshAgainForceLayout = true |
| | | } |
| | | refreshAgainAfterCurrent = true |
| | | refreshAgainForceLayout = refreshAgainForceLayout || forceLayoutRefresh |
| | | refreshAgainUseCache = refreshAgainUseCache && useCache |
| | | return |
| | | } |
| | | |
| | | refreshInProgress = true |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | let scannedApps = AppIndexer.scan() |
| | | let reconciledStore = TagEditor.reconcileScannedApps(scannedApps) |
| | | let smartStartResult = SmartStartService.runIfNeeded( |
| | | apps: scannedApps, |
| | | store: reconciledStore |
| | | ) |
| | | let store = smartStartResult.store |
| | | let apps = TagEditor.annotate(apps: scannedApps, store: store) |
| | | let quickSearchDocs = QuickSearchEngine.makeDocuments(apps: apps, store: store) |
| | | let colors = store.tags.mapValues { $0.color } |
| | | let order = TagEditor.orderedTagNames() |
| | | let result = AppLibraryController.refresh(useCache: useCache) |
| | | DispatchQueue.main.async { |
| | | allApps = apps |
| | | quickSearchDocuments = quickSearchDocs |
| | | tagColors = colors |
| | | draggedTagNames = order |
| | | rebuildDisplayGroups(apps: apps, tagOrder: order) |
| | | if quickSearchVisible { |
| | | refreshQuickSearchResults() |
| | | } |
| | | handleSmartStartRunResult(smartStartResult) |
| | | applyAppLibrarySnapshot(result.snapshot) |
| | | handleSmartStartRunResult(result.smartStartResult) |
| | | if forceLayoutRefresh { |
| | | finishDropRefreshAfterMinimumDuration() |
| | | } |
| | | refreshInProgress = false |
| | | if refreshAgainAfterCurrent { |
| | | let shouldForceLayout = refreshAgainForceLayout |
| | | let shouldUseCache = refreshAgainUseCache |
| | | refreshAgainAfterCurrent = false |
| | | refreshAgainForceLayout = false |
| | | refreshApps(forceLayoutRefresh: shouldForceLayout) |
| | | refreshAgainUseCache = true |
| | | refreshApps(forceLayoutRefresh: shouldForceLayout, useCache: shouldUseCache) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func applyAppLibrarySnapshot(_ snapshot: AppLibrarySnapshot) { |
| | | allApps = snapshot.apps |
| | | quickSearchDocuments = snapshot.quickSearchDocuments |
| | | tagColors = snapshot.tagColors |
| | | draggedTagNames = snapshot.tagOrder |
| | | rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder) |
| | | if quickSearchVisible { |
| | | refreshQuickSearchResults() |
| | | } |
| | | } |
| | | |
| | |
| | | closeOverlayOnSuccess: Bool = true, |
| | | onFailure: (() -> Void)? = nil |
| | | ) { |
| | | appDragModeActive = false |
| | | appGridInteraction.appDragModeActive = false |
| | | endTagNavReorder() |
| | | guard FileManager.default.fileExists(atPath: app.path.path) else { |
| | | AppIndexer.invalidateScanCache() |
| | | refreshApps() |
| | | onFailure?() |
| | | return |
| | | } |
| | | |
| | | let closeQuickSearchAndOverlayBeforeOpening = closeQuickSearchOnSuccess |
| | | && closeOverlayOnSuccess |
| | | && (quickSearchCloseHidesOverlay || quickSearchOnlySession) |
| | | if closeQuickSearchAndOverlayBeforeOpening { |
| | | closeQuickSearch(hideOverlayIfNeeded: true) |
| | | hideOverlay() |
| | | } |
| | | let configuration = NSWorkspace.OpenConfiguration() |
| | | NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in |
| | | DispatchQueue.main.async { |
| | |
| | | DispatchQueue.global(qos: .utility).async { |
| | | TagEditor.recordLauncherOpen(for: app.path.path) |
| | | } |
| | | if closeQuickSearchOnSuccess { |
| | | closeQuickSearch(hideOverlayIfNeeded: false) |
| | | if closeQuickSearchOnSuccess && !closeQuickSearchAndOverlayBeforeOpening { |
| | | closeQuickSearch(hideOverlayIfNeeded: closeOverlayOnSuccess) |
| | | } |
| | | if closeOverlayOnSuccess { |
| | | if closeOverlayOnSuccess && !closeQuickSearchAndOverlayBeforeOpening { |
| | | hideOverlay() |
| | | } |
| | | } |
| | |
| | | static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { |
| | | value.merge(nextValue(), uniquingKeysWith: { _, new in new }) |
| | | } |
| | | } |
| | | |
| | | private struct TagLabel: Identifiable { |
| | | var id: String { name } |
| | | let name: String |
| | | let colorIndex: Int |
| | | } |
| | | |
| | | // MARK: - NSVisualEffectView bridge |