| | |
| | | static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged") |
| | | static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange") |
| | | static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested") |
| | | static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow") |
| | | static let tagLauncherOverlayDidHide = Notification.Name("TagLauncherOverlayDidHide") |
| | | static let tagLauncherModalInteractionChanged = Notification.Name("TagLauncherModalInteractionChanged") |
| | | } |
| | | |
| | | // MARK: - Edit Phase |
| | |
| | | let colorIndex: Int |
| | | var dragModeActive: Bool = false |
| | | var isDragging: Bool = false |
| | | var dragResetToken: Int = 0 |
| | | let action: () -> Void |
| | | @State private var wiggle = false |
| | | |
| | | private var bgColor: Color { |
| | | Color(nsColor: TagColor.nsColor(for: colorIndex)) |
| | |
| | | .background(RoundedRectangle(cornerRadius: 7).fill(bgColor)) |
| | | .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1) |
| | | .scaleEffect(isDragging ? 1.05 : 1.0) |
| | | .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.8 : -1.8) : 0)) |
| | | .animation( |
| | | dragModeActive |
| | | ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true) |
| | | : .default, |
| | | value: wiggle |
| | | ) |
| | | .onChange(of: dragModeActive) { _, active in |
| | | wiggle = active |
| | | } |
| | | .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0) |
| | | .animation(.easeOut(duration: 0.08), value: isDragging) |
| | | .animation(.easeOut(duration: 0.08), value: dragModeActive) |
| | | .id("\(name)|reset-\(dragResetToken)") |
| | | .contentShape(RoundedRectangle(cornerRadius: 7)) |
| | | .onTapGesture { |
| | | if !dragModeActive { action() } |
| | | } |
| | | action() |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | let colorIndex: Int |
| | | var dragModeActive: Bool = false |
| | | var isDragging: Bool = false |
| | | var dragResetToken: Int = 0 |
| | | let action: () -> Void |
| | | @State private var wiggle = false |
| | | |
| | | private var bgColor: Color { |
| | | Color(nsColor: TagColor.nsColor(for: colorIndex)) |
| | |
| | | .background(RoundedRectangle(cornerRadius: 6).fill(bgColor)) |
| | | .shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1) |
| | | .scaleEffect(isDragging ? 1.03 : 1.0) |
| | | .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.6 : -1.6) : 0)) |
| | | .animation( |
| | | dragModeActive |
| | | ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true) |
| | | : .default, |
| | | value: wiggle |
| | | ) |
| | | .onChange(of: dragModeActive) { _, active in |
| | | wiggle = active |
| | | } |
| | | .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0) |
| | | .animation(.easeOut(duration: 0.08), value: isDragging) |
| | | .animation(.easeOut(duration: 0.08), value: dragModeActive) |
| | | .id("\(name)|reset-\(dragResetToken)") |
| | | .contentShape(RoundedRectangle(cornerRadius: 6)) |
| | | .onTapGesture { |
| | | if !dragModeActive { action() } |
| | | } |
| | | action() |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | let message: String |
| | | } |
| | | |
| | | private struct PendingUncategorizedDrop: Identifiable { |
| | | let id = UUID() |
| | | let app: AppInfo |
| | | let assignedTags: [String] |
| | | } |
| | | |
| | | private enum SmartStartNoticeMode { |
| | | case autoApplied |
| | | case suggestionOnly |
| | |
| | | @State private var tagReorderFrames: [String: CGRect] = [:] |
| | | @State private var tagNavDragModeActive = false |
| | | @State private var tagNavDragItem: String? = nil |
| | | @State private var tagNavDragResetToken = 0 |
| | | @State private var tagNavReorderFrames: [String: CGRect] = [:] |
| | | @State private var tagNavReorderDidMove = false |
| | | @State private var hoveredContainer: String? = nil // colored container lift |
| | | // Fixed interaction for "Colorless Container": hover fills persistently; click clears. |
| | | @State private var filledColorlessContainer: String? = nil |
| | |
| | | @State private var refreshInProgress = false |
| | | @State private var refreshAgainAfterCurrent = false |
| | | @State private var refreshAgainForceLayout = false |
| | | @State private var hoveredAppItemID: String? = nil |
| | | @State private var hoveredBubble: AppBubbleContext? = nil |
| | | @State private var editingBubble: AppBubbleContext? = nil |
| | | @State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil |
| | | @State private var appDragResetToken = 0 |
| | | @State private var bubbleDraftNote = "" |
| | | @FocusState private var bubbleNoteFocused: Bool |
| | | |
| | |
| | | private let editSidebarHorizontalInset: CGFloat = 12 |
| | | private let floatingControlsTrailingInset: CGFloat = 20 |
| | | private let floatingControlsReservedWidth: CGFloat = 120 |
| | | private var appBubbleDisabled: Bool { |
| | | appDragModeActive || pendingUncategorizedDrop != nil |
| | | } |
| | | private let rightSidebarFloatingClearance: CGFloat = 44 |
| | | |
| | | private var isSideLayout: Bool { |
| | |
| | | uncommonAppBubbleOverlay |
| | | smartStartNoticeOverlay |
| | | editActionFeedbackOverlay |
| | | uncategorizedDropConfirmOverlay |
| | | |
| | | if let message = dropWarningToast { |
| | | Text(message) |
| | |
| | | } |
| | | } |
| | | .onAppear { |
| | | let mousePoint = NSEvent.mouseLocation |
| | | let activeScreen = NSScreen.screens.first(where: { |
| | | NSMouseInRect(mousePoint, $0.frame, false) |
| | | }) ?? NSScreen.main |
| | | notchHeight = activeScreen?.safeAreaInsets.top ?? 0 |
| | | refreshNotchHeight() |
| | | refreshApps() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in |
| | | resetTransientDragState() |
| | | refreshNotchHeight() |
| | | refreshApps() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in |
| | | resetTransientDragState() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in |
| | | guard allApps.isEmpty, !refreshInProgress else { return } |
| | | refreshApps() |
| | | } |
| | | .onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in |
| | |
| | | if editingBubble != nil && !focused { |
| | | commitBubbleNote() |
| | | } |
| | | } |
| | | .onChange(of: pendingUncategorizedDrop != nil) { _, active in |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | userInfo: ["active": active] |
| | | ) |
| | | } |
| | | .onDisappear { |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherModalInteractionChanged, |
| | | object: nil, |
| | | userInfo: ["active": false] |
| | | ) |
| | | } |
| | | } |
| | | |
| | |
| | | TagPill(name: tag.name, colorIndex: tag.colorIndex, |
| | | dragModeActive: tagNavDragModeActive && canReorderTag(tag.name), |
| | | isDragging: tagNavDragItem == tag.name, |
| | | dragResetToken: tagNavDragResetToken, |
| | | action: { |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(tag.id) |
| | | } |
| | | scrollTo(tag.id) |
| | | activateTagNavigation(tag.id) |
| | | }) |
| | | .background(tagNavFrameReader(for: tag.name)) |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | |
| | | SideTagPill(name: tag.name, colorIndex: tag.colorIndex, |
| | | dragModeActive: tagNavDragModeActive && canReorderTag(tag.name), |
| | | isDragging: tagNavDragItem == tag.name, |
| | | dragResetToken: tagNavDragResetToken, |
| | | action: { |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(tag.id) |
| | | } |
| | | scrollTo(tag.id) |
| | | activateTagNavigation(tag.id) |
| | | }) |
| | | .background(tagNavFrameReader(for: tag.name)) |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onBubbleHover: handleBubbleHover, |
| | | onEditNote: beginEditingBubbleNote, |
| | | bubbleDisabled: appBubbleDisabled, |
| | | dragResetToken: appDragResetToken, |
| | | hoveredAppItemID: $hoveredAppItemID, |
| | | onDropApp: { path, source, copy in |
| | | dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy) |
| | | } |
| | |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onBubbleHover: handleBubbleHover, |
| | | onEditNote: beginEditingBubbleNote, |
| | | bubbleDisabled: appBubbleDisabled, |
| | | itemID: "\(group.name)|\(app.path.path)", |
| | | dragResetToken: appDragResetToken, |
| | | hoveredAppItemID: $hoveredAppItemID, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | } |
| | |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onBubbleHover: handleBubbleHover, |
| | | onEditNote: beginEditingBubbleNote, |
| | | bubbleDisabled: appBubbleDisabled, |
| | | itemID: "\(group.name)|\(app.path.path)", |
| | | dragResetToken: appDragResetToken, |
| | | hoveredAppItemID: $hoveredAppItemID, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | .frame(width: cellWidth) |
| | |
| | | .allowsHitTesting(editActionFeedback != nil) |
| | | } |
| | | |
| | | private var uncategorizedDropConfirmOverlay: some View { |
| | | GeometryReader { proxy in |
| | | if let pendingDrop = pendingUncategorizedDrop { |
| | | ZStack { |
| | | Color.black.opacity(0.14) |
| | | .ignoresSafeArea() |
| | | |
| | | UncategorizedDropConfirmBubble( |
| | | title: tr("drop.uncategorizedConfirmTitle"), |
| | | message: uncategorizedConfirmMessage(for: pendingDrop), |
| | | cancelTitle: tr("tag.cancel"), |
| | | confirmTitle: tr("edit.confirm"), |
| | | onCancel: dismissUncategorizedDropConfirm, |
| | | onConfirm: confirmPendingUncategorizedDrop |
| | | ) |
| | | .frame(width: min(540, max(340, proxy.size.width - 120))) |
| | | .position(x: proxy.size.width / 2, y: proxy.size.height / 2) |
| | | .transition(.scale(scale: 0.94).combined(with: .opacity)) |
| | | } |
| | | .zIndex(710) |
| | | } |
| | | } |
| | | .ignoresSafeArea() |
| | | .allowsHitTesting(pendingUncategorizedDrop != nil) |
| | | } |
| | | |
| | | private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback { |
| | | let appNames = selectedApps.map(\.name) |
| | | .joined(separator: localizedListSeparator) |
| | |
| | | |
| | | // MARK: - Actions |
| | | |
| | | private func handleBubbleHover(app: AppInfo, frame: CGRect, hovering: Bool) { |
| | | private func refreshNotchHeight() { |
| | | let mousePoint = NSEvent.mouseLocation |
| | | let activeScreen = NSScreen.screens.first(where: { |
| | | NSMouseInRect(mousePoint, $0.frame, false) |
| | | }) ?? NSScreen.main |
| | | notchHeight = activeScreen?.safeAreaInsets.top ?? 0 |
| | | } |
| | | |
| | | private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) { |
| | | guard !appBubbleDisabled else { |
| | | clearAppBubbleState() |
| | | return |
| | | } |
| | | guard editingBubble == nil else { return } |
| | | if hovering { |
| | | hoveredBubble = AppBubbleContext(app: app, frame: frame) |
| | | } else if hoveredBubble?.app.path == app.path { |
| | | switch event { |
| | | case .entered(let canShowBubble): |
| | | if canShowBubble { |
| | | hoveredBubble = AppBubbleContext(app: app, frame: frame) |
| | | } else { |
| | | hoveredBubble = nil |
| | | } |
| | | case .exited: |
| | | guard hoveredBubble?.app.path == app.path else { return } |
| | | hoveredBubble = nil |
| | | } |
| | | } |
| | | |
| | | private func beginEditingBubbleNote(app: AppInfo, frame: CGRect) { |
| | | guard !appBubbleDisabled else { |
| | | clearAppBubbleState() |
| | | return |
| | | } |
| | | bubbleDraftNote = currentNote(for: app) |
| | | hoveredBubble = nil |
| | | editingBubble = AppBubbleContext(app: app, frame: frame) |
| | |
| | | } |
| | | |
| | | private func dismissAppBubble() { |
| | | hoveredBubble = nil |
| | | if editingBubble != nil { |
| | | notifyAppNoteEditing(active: false) |
| | | } |
| | | editingBubble = nil |
| | | bubbleNoteFocused = false |
| | | } |
| | | |
| | | private func clearAppBubbleState() { |
| | | hoveredAppItemID = nil |
| | | hoveredBubble = nil |
| | | if editingBubble != nil { |
| | | notifyAppNoteEditing(active: false) |
| | |
| | | withAnimation(.easeInOut(duration: 0.25)) { scrollProxy?.scrollTo(id, anchor: .top) } |
| | | } |
| | | |
| | | private func activateTagNavigation(_ id: String) { |
| | | cancelTagNavReorderVisualState() |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(id) |
| | | } |
| | | scrollTo(id) |
| | | } |
| | | |
| | | private func fillColorlessContainer(_ id: String) { |
| | | guard isColorlessContainerMode else { return } |
| | | guard filledColorlessContainer != id else { return } |
| | |
| | | } |
| | | |
| | | private func tagNavReorderGesture(for tagName: String) -> some Gesture { |
| | | LongPressGesture(minimumDuration: 0.5) |
| | | .sequenced(before: DragGesture(minimumDistance: 3, coordinateSpace: .named("tagNavReorder"))) |
| | | LongPressGesture(minimumDuration: 0.35) |
| | | .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named("tagNavReorder"))) |
| | | .onChanged { value in |
| | | guard canReorderTag(tagName) else { return } |
| | | switch value { |
| | |
| | | guard canReorderTag(tagName) else { return } |
| | | if tagNavDragItem == nil { |
| | | tagNavDragItem = tagName |
| | | tagNavReorderDidMove = false |
| | | } |
| | | tagNavDragModeActive = true |
| | | } |
| | | |
| | | private func endTagNavReorder() { |
| | | if tagNavDragModeActive { |
| | | if tagNavDragModeActive && tagNavReorderDidMove { |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | | tagNavDragModeActive = false |
| | | tagNavDragItem = nil |
| | | tagNavReorderDidMove = false |
| | | tagNavDragResetToken &+= 1 |
| | | } |
| | | |
| | | private func cancelTagNavReorderVisualState() { |
| | | guard tagNavDragModeActive || tagNavDragItem != nil else { return } |
| | | tagNavDragModeActive = false |
| | | tagNavDragItem = nil |
| | | tagNavReorderDidMove = false |
| | | tagNavDragResetToken &+= 1 |
| | | } |
| | | |
| | | private func reorderTagNavItem(at location: CGPoint) { |
| | |
| | | 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 |
| | | draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination) |
| | |
| | | } |
| | | |
| | | private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) { |
| | | appDragModeActive = false |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | |
| | | if isUncategorizedDropTarget(targetTag) { |
| | | confirmAndMoveAppToUncategorized(path: path) |
| | |
| | | guard let app = allApps.first(where: { $0.path.path == path }) else { return } |
| | | let assignedTags = assignedRegularDisplayTags(for: app) |
| | | guard !assignedTags.isEmpty else { return } |
| | | guard confirmUncategorizedDrop(appName: app.name, assignedTags: assignedTags) else { return } |
| | | |
| | | TagEditor.removeTags(app.tags, from: [path]) |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | clearAppBubbleState() |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) { |
| | | pendingUncategorizedDrop = PendingUncategorizedDrop(app: app, assignedTags: assignedTags) |
| | | } |
| | | } |
| | | |
| | | private func assignedRegularDisplayTags(for app: AppInfo) -> [String] { |
| | |
| | | return result |
| | | } |
| | | |
| | | private func confirmUncategorizedDrop(appName: String, assignedTags: [String]) -> Bool { |
| | | let alert = NSAlert() |
| | | alert.alertStyle = .warning |
| | | alert.messageText = tr("drop.uncategorizedConfirmTitle") |
| | | alert.informativeText = formattedFeedbackMessage( |
| | | private func uncategorizedConfirmMessage(for pendingDrop: PendingUncategorizedDrop) -> String { |
| | | formattedFeedbackMessage( |
| | | forKey: "drop.uncategorizedConfirmMessage", |
| | | replacements: [ |
| | | "%appName%": appName, |
| | | "%tagCount%": "\(assignedTags.count)", |
| | | "%tagNames%": assignedTags.joined(separator: localizedListSeparator) |
| | | "%appName%": pendingDrop.app.name, |
| | | "%tagCount%": "\(pendingDrop.assignedTags.count)", |
| | | "%tagNames%": pendingDrop.assignedTags.joined(separator: localizedListSeparator) |
| | | ] |
| | | ) |
| | | alert.addButton(withTitle: tr("edit.confirm")) |
| | | alert.addButton(withTitle: tr("tag.cancel")) |
| | | return alert.runModal() == .alertFirstButtonReturn |
| | | } |
| | | |
| | | private func dismissUncategorizedDropConfirm() { |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | pendingUncategorizedDrop = nil |
| | | } |
| | | } |
| | | |
| | | private func confirmPendingUncategorizedDrop() { |
| | | guard let pendingDrop = pendingUncategorizedDrop else { return } |
| | | let path = pendingDrop.app.path.path |
| | | let tags = pendingDrop.app.tags |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.16)) { |
| | | pendingUncategorizedDrop = nil |
| | | } |
| | | TagEditor.removeTags(tags, from: [path]) |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func isUncategorizedDropTarget(_ targetTag: String) -> Bool { |
| | |
| | | private func setAppDragMode(_ active: Bool) { |
| | | if active { |
| | | endTagNavReorder() |
| | | clearAppBubbleState() |
| | | } |
| | | appDragModeActive = active |
| | | if active { |
| | |
| | | } |
| | | } |
| | | |
| | | private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) { |
| | | AppDragCoordinator.shared.cancelDrag() |
| | | appDragModeActive = false |
| | | appDragResetToken &+= 1 |
| | | tagNavDragModeActive = false |
| | | tagNavDragItem = nil |
| | | tagNavReorderDidMove = false |
| | | tagNavDragResetToken &+= 1 |
| | | dragItem = nil |
| | | hoveredAppItemID = nil |
| | | hoveredContainer = nil |
| | | clearAppBubbleState() |
| | | if !keepingPendingUncategorizedDrop { |
| | | pendingUncategorizedDrop = nil |
| | | } |
| | | } |
| | | |
| | | func refreshApps(forceLayoutRefresh: Bool = false) { |
| | | guard !refreshInProgress else { |
| | | refreshAgainAfterCurrent = true |
| | | refreshAgainForceLayout = refreshAgainForceLayout || forceLayoutRefresh |
| | | if forceLayoutRefresh { |
| | | refreshAgainAfterCurrent = true |
| | | refreshAgainForceLayout = true |
| | | } |
| | | return |
| | | } |
| | | |