| | |
| | | let arrowOffset: CGFloat |
| | | } |
| | | |
| | | private struct AppGridInteractionState { |
| | | var appDragModeActive = false |
| | | var dropWarningToast: String? = nil |
| | | var dropRefreshVisible = false |
| | | var dropRefreshStartedAt: Date? = nil |
| | | var hoveredBubble: AppBubbleContext? = nil |
| | | var editingBubble: AppBubbleContext? = nil |
| | | var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil |
| | | var pendingTagRemovalDrop: PendingTagRemovalDrop? = nil |
| | | var tagRemovalDropSuppressFuturePrompt = false |
| | | var appDragResetToken = 0 |
| | | var bubbleDraftNote = "" |
| | | } |
| | | |
| | | private struct EditActionFeedback: Identifiable { |
| | | let id = UUID() |
| | | let title: String |
| | |
| | | @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 = "" |
| | | @FocusState private var bubbleNoteFocused: Bool |
| | | |
| | | // Quick Search |
| | |
| | | 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 let rightSidebarFloatingClearance: CGFloat = 44 |
| | | |
| | |
| | | |
| | | 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) |
| | |
| | | ) |
| | | } |
| | | .onChange(of: bubbleNoteFocused) { _, focused in |
| | | if editingBubble != nil && !focused { |
| | | if appGridInteraction.editingBubble != nil && !focused { |
| | | commitBubbleNote() |
| | | } |
| | | } |
| | |
| | | quickSearchErrorMessage = nil |
| | | refreshQuickSearchResults() |
| | | } |
| | | .onChange(of: pendingUncategorizedDrop != nil) { _, active in |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | userInfo: ["active": active] |
| | | ) |
| | | .onChange(of: appGridInteraction.pendingUncategorizedDrop != nil) { _, _ in |
| | | publishModalInteractionState() |
| | | } |
| | | .onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in |
| | | publishModalInteractionState() |
| | | } |
| | | .onDisappear { |
| | | NotificationCenter.default.post( |
| | |
| | | userInfo: ["active": false] |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private func publishModalInteractionState() { |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil] |
| | | ) |
| | | } |
| | | |
| | | /// Set edit phase with synchronous notification BEFORE state change. |
| | |
| | | |
| | | private var canOpenQuickSearch: Bool { |
| | | editPhase == .none |
| | | && pendingUncategorizedDrop == nil |
| | | && appGridInteraction.pendingUncategorizedDrop == nil |
| | | && smartStartNotice == nil |
| | | && !dropRefreshVisible |
| | | && !appGridInteraction.dropRefreshVisible |
| | | && !quickSearchVisible |
| | | } |
| | | |
| | |
| | | |
| | | 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 { |
| | |
| | | |
| | | private var uncategorizedDropConfirmOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let pendingDrop = pendingUncategorizedDrop { |
| | | if let pendingDrop = appGridInteraction.pendingUncategorizedDrop { |
| | | ZStack { |
| | | Color.black.opacity(0.14) |
| | | .ignoresSafeArea() |
| | |
| | | } |
| | | } |
| | | .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 { |
| | |
| | | } |
| | | |
| | | private func handleAppGridScrollActivity() { |
| | | 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 |
| | |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | clearAppBubbleState() |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) { |
| | | pendingUncategorizedDrop = PendingUncategorizedDrop( |
| | | appGridInteraction.pendingUncategorizedDrop = PendingUncategorizedDrop( |
| | | app: app, |
| | | assignedTags: assignedTags, |
| | | removableTags: removableTags |
| | |
| | | private func dismissUncategorizedDropConfirm() { |
| | | 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 |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | pendingUncategorizedDrop = nil |
| | | appGridInteraction.pendingUncategorizedDrop = nil |
| | | } |
| | | guard !tags.isEmpty else { return } |
| | | TagEditor.removeTags(tags, from: [path]) |
| | |
| | | } |
| | | |
| | | private func dismissTagRemovalDropConfirm() { |
| | | resetTransientDragState(keepingPendingTagRemovalDrop: true) |
| | | 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 |
| | | if tagRemovalDropSuppressFuturePrompt { |
| | | let shouldSuppressFuturePrompt = appGridInteraction.tagRemovalDropSuppressFuturePrompt |
| | | if shouldSuppressFuturePrompt { |
| | | skipTagRemovalDropConfirm = true |
| | | } |
| | | resetTransientDragState(keepingPendingTagRemovalDrop: true) |
| | | tagRemovalDropSuppressFuturePrompt = false |
| | | appGridInteraction.tagRemovalDropSuppressFuturePrompt = false |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | pendingTagRemovalDrop = nil |
| | | appGridInteraction.pendingTagRemovalDrop = nil |
| | | } |
| | | removeTagFromDroppedApp(app: app, tagName: tagName) |
| | | DispatchQueue.main.async { |
| | | removeTagFromDroppedApp(app: app, tagName: tagName) |
| | | } |
| | | } |
| | | |
| | | private func removeTagFromDroppedApp(app: AppInfo, tagName: String) { |
| | |
| | | |
| | | 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() |
| | | } |
| | | appDragModeActive = active |
| | | appGridInteraction.appDragModeActive = active |
| | | if active { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 8) { |
| | | if appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag { |
| | | appDragModeActive = false |
| | | if appGridInteraction.appDragModeActive && !AppDragCoordinator.shared.hasActiveDrag { |
| | | appGridInteraction.appDragModeActive = false |
| | | } |
| | | } |
| | | } |
| | |
| | | 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 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 |
| | | } |
| | | } |
| | | |
| | |
| | | closeOverlayOnSuccess: Bool = true, |
| | | onFailure: (() -> Void)? = nil |
| | | ) { |
| | | appDragModeActive = false |
| | | appGridInteraction.appDragModeActive = false |
| | | endTagNavReorder() |
| | | let configuration = NSWorkspace.OpenConfiguration() |
| | | NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) { _, error in |