| | |
| | | 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 tagNavigationLastHoverScrollID: String? = nil |
| | | var tagNavigationLastHoverScrollAt: Date? = nil |
| | | } |
| | | |
| | | private struct EditActionFeedback: Identifiable { |
| | |
| | | let id = UUID() |
| | | let app: AppInfo |
| | | let tagName: String |
| | | } |
| | | |
| | | private enum SmartStartNoticeMode { |
| | | case autoApplied |
| | | case suggestionOnly |
| | | case manuallyApplied |
| | | } |
| | | |
| | | private struct SmartStartNotice: Identifiable { |
| | | let id = UUID() |
| | | let mode: SmartStartNoticeMode |
| | | let title: String |
| | | let message: String |
| | | let summary: SmartStartSummary |
| | | } |
| | | |
| | | struct ContentView: View { |
| | |
| | | @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 |
| | |
| | | || 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 |
| | |
| | | } |
| | | .zIndex(899) |
| | | |
| | | QuickSearchOverlayView( |
| | | QuickSearchPanelPresentationView( |
| | | query: $quickSearchQuery, |
| | | results: quickSearchResults, |
| | | selectedID: quickSearchSelectedID, |
| | |
| | | selectionScrollToken: quickSearchSelectionScrollToken, |
| | | isLoading: quickSearchDocuments.isEmpty && refreshInProgress, |
| | | 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 = !(quickSearchDocuments.isEmpty && refreshInProgress) |
| | | && 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)))) |
| | | } |
| | |
| | | contentInsets: isHorizontal |
| | | ? NSEdgeInsets(top: 3, left: 24, bottom: 3, right: tagPosition == "top" ? floatingControlsReservedWidth : 24) |
| | | : NSEdgeInsets(top: 12, left: 12, bottom: 12, right: 12), |
| | | dragModeActive: tagNavDragModeActive, |
| | | draggingItemID: tagNavDragItem, |
| | | onActivate: { tagID in |
| | | activateTagNavigation(tagID) |
| | | }, |
| | | onHover: { tagID in |
| | | handleTagNavigationHover(tagID) |
| | | onHoverChange: { tagID, active in |
| | | handleTagNavigationHover(tagID, active: active) |
| | | }, |
| | | canReorder: { tagID in |
| | | canReorderTag(tagID) |
| | | }, |
| | | onReorderBegan: { tagID in |
| | | beginTagNavReorder(tagID) |
| | | }, |
| | | onReorderMoved: { tagID, targetID in |
| | | reorderTagNavItem(fromName: tagID, to: targetID) |
| | | }, |
| | | onReorderEnded: { |
| | | endTagNavReorder() |
| | | } |
| | | ) |
| | | } |
| | |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | | .highPriorityGesture(tagNavReorderGesture(for: tag.name)) |
| | | .onHover { hovering in |
| | | if hovering { |
| | | handleTagNavigationHover(tag.id) |
| | | } |
| | | handleTagNavigationHover(tag.id, active: hovering) |
| | | } |
| | | } |
| | | } |
| | |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | | .highPriorityGesture(tagNavReorderGesture(for: tag.name)) |
| | | .onHover { hovering in |
| | | if hovering { |
| | | handleTagNavigationHover(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 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 |
| | | |
| | |
| | | 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, |
| | |
| | | |
| | | 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 { |
| | |
| | | } |
| | | |
| | | private func handleAppGridScrollActivity() { |
| | | appGridInteraction.hoveredBubble = nil |
| | | if appGridInteraction.hoveredBubble != nil { |
| | | appGridInteraction.hoveredBubble = nil |
| | | } |
| | | } |
| | | |
| | | private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) { |
| | |
| | | scrollTo(id) |
| | | } |
| | | |
| | | private func handleTagNavigationHover(_ id: String) { |
| | | private func handleTagNavigationHover(_ id: String, active: Bool) { |
| | | guard active else { |
| | | if appGridInteraction.tagNavigationHoveredGroupName == id { |
| | | appGridInteraction.tagNavigationHoveredGroupName = nil |
| | | } |
| | | return |
| | | } |
| | | |
| | | appGridInteraction.tagNavigationHoveredGroupName = id |
| | | fillColorlessContainer(id) |
| | | scrollToTagFromHover(id) |
| | | } |
| | | |
| | | private func scrollToTagFromHover(_ id: String) { |
| | | let now = Date() |
| | | if appGridInteraction.tagNavigationLastHoverScrollID == id, |
| | | let lastScrollAt = appGridInteraction.tagNavigationLastHoverScrollAt, |
| | | now.timeIntervalSince(lastScrollAt) < tagNavigationHoverScrollInterval { |
| | | return |
| | | } |
| | | appGridInteraction.tagNavigationLastHoverScrollID = id |
| | | appGridInteraction.tagNavigationLastHoverScrollAt = now |
| | | scrollTo(id) |
| | | } |
| | | |
| | |
| | | private func 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 |
| | |
| | | guard !removableTags.isEmpty else { return } |
| | | let assignedTags = assignedRegularDisplayTags(for: removableTags) |
| | | |
| | | if skipUncategorizedDropConfirm { |
| | | moveDroppedAppToUncategorized(path: app.path.path, tags: removableTags) |
| | | return |
| | | } |
| | | |
| | | clearAppBubbleState() |
| | | appGridInteraction.uncategorizedDropSuppressFuturePrompt = false |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) { |
| | | appGridInteraction.pendingUncategorizedDrop = PendingUncategorizedDrop( |
| | | app: app, |
| | |
| | | } |
| | | |
| | | private func dismissUncategorizedDropConfirm() { |
| | | appGridInteraction.uncategorizedDropSuppressFuturePrompt = false |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | appGridInteraction.pendingUncategorizedDrop = nil |
| | |
| | | guard let pendingDrop = appGridInteraction.pendingUncategorizedDrop else { return } |
| | | let path = pendingDrop.app.path.path |
| | | let tags = pendingDrop.removableTags |
| | | let shouldSuppressFuturePrompt = appGridInteraction.uncategorizedDropSuppressFuturePrompt |
| | | if shouldSuppressFuturePrompt { |
| | | skipUncategorizedDropConfirm = true |
| | | } |
| | | appGridInteraction.uncategorizedDropSuppressFuturePrompt = false |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | appGridInteraction.pendingUncategorizedDrop = nil |
| | | } |
| | | moveDroppedAppToUncategorized(path: path, tags: tags) |
| | | } |
| | | |
| | | private func moveDroppedAppToUncategorized(path: String, tags: [String]) { |
| | | guard !tags.isEmpty else { return } |
| | | TagEditor.removeTags(tags, from: [path]) |
| | | showDropRefresh() |
| | |
| | | |
| | | 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() |
| | | 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() |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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() |
| | | } |
| | | } |
| | | |
| | | private func launchApp( |
| | | _ app: AppInfo, |
| | | closeQuickSearchOnSuccess: Bool = false, |