| | |
| | | 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(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(dragResetToken) |
| | | .contentShape(RoundedRectangle(cornerRadius: 6)) |
| | | .onTapGesture { |
| | | if !dragModeActive { action() } |
| | | action() |
| | | } |
| | | } |
| | | } |
| | |
| | | @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 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 |
| | | |
| | |
| | | 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 } |
| | |
| | | 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) |
| | |
| | | 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) |
| | |
| | | onEditNote: beginEditingBubbleNote, |
| | | bubbleDisabled: appBubbleDisabled, |
| | | itemID: "\(group.name)|\(app.path.path)", |
| | | dragResetToken: appDragResetToken, |
| | | hoveredAppItemID: $hoveredAppItemID, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | |
| | | onEditNote: beginEditingBubbleNote, |
| | | bubbleDisabled: appBubbleDisabled, |
| | | itemID: "\(group.name)|\(app.path.path)", |
| | | dragResetToken: appDragResetToken, |
| | | hoveredAppItemID: $hoveredAppItemID, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | |
| | | 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) |
| | |
| | | } |
| | | |
| | | private func dismissUncategorizedDropConfirm() { |
| | | resetTransientDragState(keepingPendingUncategorizedDrop: true) |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | pendingUncategorizedDrop = nil |
| | | } |
| | |
| | | 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 |
| | | } |
| | |
| | | } |
| | | } |
| | | |
| | | 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 { |
| | | if forceLayoutRefresh { |