| | |
| | | struct TagPill: View { |
| | | let name: String |
| | | let colorIndex: Int |
| | | var dragModeActive: Bool = false |
| | | var isDragging: Bool = false |
| | | let action: () -> Void |
| | | @State private var wiggle = false |
| | | |
| | | private var bgColor: Color { |
| | | Color(nsColor: TagColor.nsColor(for: colorIndex)) |
| | |
| | | } |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | Text(name) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(textColor) |
| | | .padding(.horizontal, 12) |
| | | .padding(.vertical, 6) |
| | | .background(RoundedRectangle(cornerRadius: 7).fill(bgColor)) |
| | | .shadow(color: .black.opacity(0.2), radius: 3, y: 1) |
| | | Text(name) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(textColor) |
| | | .padding(.horizontal, 12) |
| | | .padding(.vertical, 6) |
| | | .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 |
| | | } |
| | | .contentShape(RoundedRectangle(cornerRadius: 7)) |
| | | .onTapGesture { |
| | | if !dragModeActive { action() } |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | } |
| | | |
| | |
| | | struct SideTagPill: View { |
| | | let name: String |
| | | let colorIndex: Int |
| | | var dragModeActive: Bool = false |
| | | var isDragging: Bool = false |
| | | let action: () -> Void |
| | | @State private var wiggle = false |
| | | |
| | | private var bgColor: Color { |
| | | Color(nsColor: TagColor.nsColor(for: colorIndex)) |
| | |
| | | } |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | Text(name) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(textColor) |
| | | .padding(.horizontal, 10).padding(.vertical, 5) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .background(RoundedRectangle(cornerRadius: 6).fill(bgColor)) |
| | | .shadow(color: .black.opacity(0.2), radius: 3, y: 1) |
| | | Text(name) |
| | | .font(.system(size: 13, weight: .medium)) |
| | | .foregroundStyle(textColor) |
| | | .padding(.horizontal, 10).padding(.vertical, 5) |
| | | .frame(maxWidth: .infinity, alignment: .leading) |
| | | .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 |
| | | } |
| | | .contentShape(RoundedRectangle(cornerRadius: 6)) |
| | | .onTapGesture { |
| | | if !dragModeActive { action() } |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | } |
| | | |
| | |
| | | @State private var successToast: String? = nil |
| | | @State private var draggedTagNames: [String] = [] // live drag order |
| | | @State private var dragItem: String? = nil // currently dragged tag |
| | | @State private var tagReorderFrames: [String: CGRect] = [:] |
| | | @State private var tagNavDragModeActive = false |
| | | @State private var tagNavDragItem: String? = nil |
| | | @State private var tagNavDismissMonitor: Any? = nil |
| | | @State private var tagNavReorderFrames: [String: CGRect] = [:] |
| | | @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 appDragModeActive = false |
| | | @State private var dropWarningToast: String? = nil |
| | | @State private var dropRefreshVisible = false |
| | | @State private var dropRefreshStartedAt: Date? = nil |
| | | @State private var layoutRefreshID = UUID() |
| | | |
| | | // Configurable defaults |
| | | @AppStorage("defaultGroupName") private var defaultGroupName = "Other" |
| | |
| | | .transition(.scale(scale: 0.96).combined(with: .opacity)) |
| | | .allowsHitTesting(false) |
| | | } |
| | | |
| | | if dropRefreshVisible { |
| | | Color.black.opacity(0.08) |
| | | .ignoresSafeArea() |
| | | .transition(.opacity) |
| | | .allowsHitTesting(false) |
| | | |
| | | ProgressView() |
| | | .progressViewStyle(.circular) |
| | | .controlSize(.large) |
| | | .scaleEffect(1.15) |
| | | .padding(24) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 12) |
| | | .fill(.ultraThickMaterial) |
| | | .shadow(color: .black.opacity(0.26), radius: 24, y: 12) |
| | | ) |
| | | .transition(.scale(scale: 0.92).combined(with: .opacity)) |
| | | .allowsHitTesting(false) |
| | | } |
| | | } |
| | | .onAppear { |
| | | let mousePoint = NSEvent.mouseLocation |
| | |
| | | object: nil, |
| | | userInfo: ["active": active] |
| | | ) |
| | | } |
| | | .onChange(of: tagNavDragModeActive) { _, active in |
| | | updateTagNavDismissMonitor(active: active) |
| | | } |
| | | .onDisappear { |
| | | removeTagNavDismissMonitor() |
| | | } |
| | | } |
| | | |
| | |
| | | topLayout |
| | | } |
| | | |
| | | if tagNavDragModeActive && tagNavDragItem == nil { |
| | | Color.clear |
| | | .contentShape(Rectangle()) |
| | | .ignoresSafeArea() |
| | | .onTapGesture { |
| | | endTagNavReorder() |
| | | } |
| | | .zIndex(1) |
| | | } |
| | | |
| | | Button { |
| | | setEditPhase(.editingApps) |
| | | } label: { |
| | |
| | | .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20) |
| | | .padding(.trailing, 20) |
| | | .keyboardShortcut("e", modifiers: .control) |
| | | .zIndex(2) |
| | | } |
| | | } |
| | | |
| | |
| | | HStack(spacing: 8) { |
| | | ForEach(tagLabels) { tag in |
| | | TagPill(name: tag.name, colorIndex: tag.colorIndex, |
| | | dragModeActive: tagNavDragModeActive && canReorderTag(tag.name), |
| | | isDragging: tagNavDragItem == tag.name, |
| | | action: { |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(tag.id) |
| | | } |
| | | scrollTo(tag.id) |
| | | }) |
| | | .background(tagNavFrameReader(for: tag.name)) |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | | .highPriorityGesture(tagNavReorderGesture(for: tag.name)) |
| | | .onHover { hovering in |
| | | if hovering { |
| | | fillColorlessContainer(tag.id) |
| | |
| | | } |
| | | } |
| | | } |
| | | }.padding(.horizontal, 24) |
| | | } |
| | | .padding(.horizontal, 24) |
| | | .coordinateSpace(name: "tagNavReorder") |
| | | .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in |
| | | tagNavReorderFrames = frames |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | VStack(spacing: 6) { |
| | | ForEach(tagLabels) { tag in |
| | | SideTagPill(name: tag.name, colorIndex: tag.colorIndex, |
| | | dragModeActive: tagNavDragModeActive && canReorderTag(tag.name), |
| | | isDragging: tagNavDragItem == tag.name, |
| | | action: { |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(tag.id) |
| | | } |
| | | scrollTo(tag.id) |
| | | }) |
| | | .background(tagNavFrameReader(for: tag.name)) |
| | | .zIndex(tagNavDragItem == tag.name ? 1 : 0) |
| | | .highPriorityGesture(tagNavReorderGesture(for: tag.name)) |
| | | .onHover { hovering in |
| | | if hovering { |
| | | fillColorlessContainer(tag.id) |
| | |
| | | } |
| | | } |
| | | } |
| | | }.padding(12) |
| | | } |
| | | .padding(12) |
| | | .coordinateSpace(name: "tagNavReorder") |
| | | .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in |
| | | tagNavReorderFrames = frames |
| | | } |
| | | }.frame(width: 135) |
| | | } |
| | | |
| | |
| | | flatGrid |
| | | } |
| | | } |
| | | .id(layoutRefreshID) |
| | | } |
| | | |
| | | private var flatGrid: some View { |
| | |
| | | |
| | | private func estimatedCardHeight(_ group: TagGroup, width: CGFloat) -> CGFloat { |
| | | let inner = width - 32 |
| | | let itemW = iconSize + 28 + 6 |
| | | let itemW = AppGridItem.stableWidth(iconSize: iconSize) + 6 |
| | | let perRow = max(1, Int(inner / itemW)) |
| | | let rows = (group.apps.count + perRow - 1) / perRow |
| | | return 32 + CGFloat(rows) * (iconSize + 30) |
| | | return 32 |
| | | + CGFloat(rows) * AppGridItem.stableHeight(iconSize: iconSize) |
| | | + CGFloat(max(0, rows - 1)) * 2 |
| | | } |
| | | |
| | | private func masonryCard(_ group: TagGroup, width: CGFloat) -> some View { |
| | |
| | | Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1) |
| | | .layoutPriority(0) |
| | | } |
| | | let itemSize = iconSize + 28 |
| | | let itemSize = AppGridItem.stableWidth(iconSize: iconSize) |
| | | LazyVGrid( |
| | | columns: [GridItem(.adaptive(minimum: itemSize, maximum: itemSize + 36), spacing: 6)], |
| | | spacing: 2 |
| | |
| | | } |
| | | |
| | | private func preferredGridContainersPerRow(availableWidth: CGFloat) -> Int { |
| | | let minCardWidth = max(260, iconSize * 3 + 88) |
| | | let minCardWidth = max(260, AppGridItem.stableWidth(iconSize: iconSize) * 3 + 64) |
| | | if availableWidth >= minCardWidth * 3 + 32 { return 3 } |
| | | if availableWidth >= minCardWidth * 2 + 16 { return 2 } |
| | | return 1 |
| | |
| | | |
| | | private func iconColumns(width: CGFloat) -> Int { |
| | | let inner = width - 32 |
| | | let itemW = iconSize + 34 |
| | | let itemW = AppGridItem.stableWidth(iconSize: iconSize) + 6 |
| | | return max(1, Int((inner + 6) / itemW)) |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | private func iconCellHeight() -> CGFloat { |
| | | iconSize + (hideAppNames ? 16 : 42) |
| | | AppGridItem.stableHeight(iconSize: iconSize) |
| | | } |
| | | |
| | | private func appRows(_ apps: [AppInfo], columns: Int, fixedRows: Int) -> [[AppInfo]] { |
| | |
| | | Spacer() |
| | | Button(tr("edit.confirm")) { confirmAssign() } |
| | | .buttonStyle(.borderedProminent) |
| | | .disabled(selectedAppPaths.isEmpty || selectedTagNames.isEmpty) |
| | | .disabled(selectedAppPaths.isEmpty) |
| | | } |
| | | .padding(.horizontal, 24) |
| | | .padding(.top, notchHeight > 0 ? notchHeight + 10 : 20) |
| | |
| | | VStack(spacing: 4) { |
| | | ForEach(sortedTagNames, id: \.self) { tagName in |
| | | selectableTagItem(tagName) |
| | | .onDrag { |
| | | dragItem = tagName |
| | | return NSItemProvider(object: tagName as NSString) |
| | | } |
| | | .onDrop(of: [.text], isTargeted: nil) { providers, _ in |
| | | guard let fromName = dragItem, |
| | | var names = draggedTagNames as [String]?, |
| | | let fromIdx = names.firstIndex(of: fromName), |
| | | let toIdx = names.firstIndex(of: tagName), |
| | | fromIdx != toIdx |
| | | else { return false } |
| | | let toOffset = toIdx > fromIdx ? toIdx + 1 : toIdx |
| | | names.move(fromOffsets: [fromIdx], toOffset: toOffset) |
| | | draggedTagNames = names |
| | | TagEditor.reorderTags(names) |
| | | dragItem = nil |
| | | return true |
| | | } |
| | | .opacity(dragItem == tagName ? 0.72 : 1.0) |
| | | .scaleEffect(dragItem == tagName ? 1.03 : 1.0) |
| | | .background( |
| | | GeometryReader { proxy in |
| | | Color.clear.preference( |
| | | key: TagReorderFramePreferenceKey.self, |
| | | value: [tagName: proxy.frame(in: .named("tagReorderList"))] |
| | | ) |
| | | } |
| | | ) |
| | | .zIndex(dragItem == tagName ? 1 : 0) |
| | | .highPriorityGesture(tagReorderGesture(for: tagName)) |
| | | } |
| | | }.padding(12) |
| | | }.frame(width: 155) |
| | | } |
| | | .padding(12) |
| | | .coordinateSpace(name: "tagReorderList") |
| | | .onPreferenceChange(TagReorderFramePreferenceKey.self) { frames in |
| | | tagReorderFrames = frames |
| | | } |
| | | } |
| | | .frame(width: 155) |
| | | } |
| | | Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1) |
| | | |
| | |
| | | .padding(.bottom, 6) |
| | | |
| | | LazyVGrid( |
| | | columns: [GridItem(.adaptive(minimum: iconSize + 28, maximum: iconSize + 64), spacing: 6)], |
| | | columns: [ |
| | | GridItem( |
| | | .adaptive( |
| | | minimum: AppGridItem.stableWidth(iconSize: iconSize), |
| | | maximum: AppGridItem.stableWidth(iconSize: iconSize) + 36 |
| | | ), |
| | | spacing: 6 |
| | | ) |
| | | ], |
| | | spacing: 2 |
| | | ) { |
| | | ForEach(group.apps) { app in editableAppItem(app) } |
| | |
| | | } |
| | | } |
| | | |
| | | private func confirmAssign() { |
| | | guard !selectedAppPaths.isEmpty, !selectedTagNames.isEmpty else { return } |
| | | let paths = selectedAppPaths.map { $0.path } |
| | | for tagName in selectedTagNames { |
| | | TagEditor.assignTag(tagName, color: tagColors[tagName] ?? 0, to: paths) |
| | | private func tagReorderGesture(for tagName: String) -> some Gesture { |
| | | DragGesture(minimumDistance: 4, coordinateSpace: .named("tagReorderList")) |
| | | .onChanged { value in |
| | | if dragItem == nil { |
| | | dragItem = tagName |
| | | } |
| | | guard dragItem == tagName else { return } |
| | | reorderDraggedTag(at: value.location) |
| | | } |
| | | .onEnded { _ in |
| | | dragItem = nil |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | | } |
| | | |
| | | private func reorderDraggedTag(at location: CGPoint) { |
| | | guard let fromName = dragItem, |
| | | let targetName = tagReorderFrames.first(where: { $0.value.contains(location) })?.key, |
| | | fromName != targetName, |
| | | let fromIndex = draggedTagNames.firstIndex(of: fromName), |
| | | let toIndex = draggedTagNames.firstIndex(of: targetName) |
| | | else { return } |
| | | |
| | | withAnimation(.easeInOut(duration: 0.14)) { |
| | | let destination = toIndex > fromIndex ? toIndex + 1 : toIndex |
| | | draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination) |
| | | } |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | | |
| | | private func confirmAssign() { |
| | | guard !selectedAppPaths.isEmpty else { return } |
| | | let paths = selectedAppPaths.map { $0.path } |
| | | let tags = sortedTagNames.filter { selectedTagNames.contains($0) } |
| | | TagEditor.setTags(tags, to: paths) |
| | | selectedAppPaths = []; selectedTagNames = [] |
| | | refreshApps() |
| | | withAnimation { successToast = tr("edit.success") } |
| | |
| | | private func editableAppItem(_ app: AppInfo) -> some View { |
| | | let isSelected = selectedAppPaths.contains(app.path) |
| | | return Button { |
| | | if isSelected { selectedAppPaths.remove(app.path) } else { selectedAppPaths.insert(app.path) } |
| | | toggleEditableAppSelection(app) |
| | | } label: { |
| | | VStack(spacing: 4) { |
| | | ZStack(alignment: .topTrailing) { |
| | |
| | | .opacity(isSelected ? 1.0 : 0.65) |
| | | } |
| | | .buttonStyle(.plain) |
| | | } |
| | | |
| | | private func toggleEditableAppSelection(_ app: AppInfo) { |
| | | if selectedAppPaths.contains(app.path) { |
| | | selectedAppPaths.remove(app.path) |
| | | } else { |
| | | selectedAppPaths.insert(app.path) |
| | | } |
| | | syncSelectedTagsFromSelectedApps() |
| | | } |
| | | |
| | | private func syncSelectedTagsFromSelectedApps() { |
| | | let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) } |
| | | guard !selectedApps.isEmpty else { |
| | | selectedTagNames = [] |
| | | return |
| | | } |
| | | |
| | | let editableTags = Set(sortedTagNames) |
| | | if selectedApps.count == 1 { |
| | | selectedTagNames = Set(selectedApps[0].tags.filter { editableTags.contains($0) }) |
| | | return |
| | | } |
| | | |
| | | selectedTagNames = selectedApps.reduce(into: Set<String>()) { result, app in |
| | | result.formUnion(app.tags.filter { editableTags.contains($0) }) |
| | | } |
| | | } |
| | | |
| | | // MARK: - Computed |
| | |
| | | filledColorlessContainer = (filledColorlessContainer == id) ? nil : id |
| | | } |
| | | |
| | | private func canReorderTag(_ name: String) -> Bool { |
| | | tagColors[name] != nil && name != "Mac自带" && name != defaultGroupName |
| | | } |
| | | |
| | | private func tagNavFrameReader(for tagName: String) -> some View { |
| | | GeometryReader { proxy in |
| | | Color.clear.preference( |
| | | key: TagNavReorderFramePreferenceKey.self, |
| | | value: canReorderTag(tagName) |
| | | ? [tagName: proxy.frame(in: .named("tagNavReorder"))] |
| | | : [:] |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private func tagNavReorderGesture(for tagName: String) -> some Gesture { |
| | | LongPressGesture(minimumDuration: 0.5) |
| | | .sequenced(before: DragGesture(minimumDistance: 3, coordinateSpace: .named("tagNavReorder"))) |
| | | .onChanged { value in |
| | | guard canReorderTag(tagName) else { return } |
| | | switch value { |
| | | case .first(true): |
| | | beginTagNavReorder(tagName) |
| | | case .second(true, let drag?): |
| | | if tagNavDragItem == nil { |
| | | beginTagNavReorder(tagName) |
| | | } |
| | | reorderTagNavItem(at: drag.location) |
| | | default: |
| | | break |
| | | } |
| | | } |
| | | .onEnded { value in |
| | | guard canReorderTag(tagName) else { return } |
| | | if case .second(true, let drag?) = value { |
| | | reorderTagNavItem(at: drag.location) |
| | | } |
| | | endTagNavReorder() |
| | | } |
| | | } |
| | | |
| | | private func beginTagNavReorder(_ tagName: String) { |
| | | guard canReorderTag(tagName) else { return } |
| | | if tagNavDragItem == nil { |
| | | tagNavDragItem = tagName |
| | | } |
| | | tagNavDragModeActive = true |
| | | } |
| | | |
| | | private func endTagNavReorder() { |
| | | if tagNavDragModeActive { |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | | tagNavDragModeActive = false |
| | | tagNavDragItem = nil |
| | | removeTagNavDismissMonitor() |
| | | } |
| | | |
| | | private func updateTagNavDismissMonitor(active: Bool) { |
| | | if active { |
| | | guard tagNavDismissMonitor == nil else { return } |
| | | tagNavDismissMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { event in |
| | | guard tagNavDragModeActive, editPhase == .none else { return event } |
| | | endTagNavReorder() |
| | | return nil |
| | | } |
| | | } else { |
| | | removeTagNavDismissMonitor() |
| | | } |
| | | } |
| | | |
| | | private func removeTagNavDismissMonitor() { |
| | | if let monitor = tagNavDismissMonitor { |
| | | NSEvent.removeMonitor(monitor) |
| | | tagNavDismissMonitor = nil |
| | | } |
| | | } |
| | | |
| | | private func reorderTagNavItem(at location: CGPoint) { |
| | | guard let fromName = tagNavDragItem, |
| | | let targetName = tagNavReorderFrames.first(where: { $0.value.contains(location) })?.key, |
| | | fromName != targetName, |
| | | let fromIndex = draggedTagNames.firstIndex(of: fromName), |
| | | let toIndex = draggedTagNames.firstIndex(of: targetName) |
| | | else { return } |
| | | |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) { |
| | | let destination = toIndex > fromIndex ? toIndex + 1 : toIndex |
| | | draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination) |
| | | } |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | } |
| | | |
| | | private func handleAppDrop(_ providers: [NSItemProvider], targetTag: String) -> Bool { |
| | | guard let provider = providers.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) }) else { |
| | | return false |
| | |
| | | color: tagColors[targetTag] ?? 0, |
| | | copy: copy |
| | | ) |
| | | refreshApps() |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func isSystemDefaultDropTarget(_ targetTag: String) -> Bool { |
| | |
| | | } |
| | | } |
| | | |
| | | private func showDropRefresh() { |
| | | dropRefreshStartedAt = Date() |
| | | withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) { |
| | | dropRefreshVisible = true |
| | | } |
| | | } |
| | | |
| | | private func finishDropRefreshAfterMinimumDuration() { |
| | | let minimumDuration: TimeInterval = 0.85 |
| | | let elapsed = 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 |
| | | } |
| | | dropRefreshStartedAt = nil |
| | | } |
| | | } |
| | | |
| | | private func setAppDragMode(_ active: Bool) { |
| | | if active { |
| | | endTagNavReorder() |
| | | } |
| | | appDragModeActive = active |
| | | if active { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 8) { |
| | |
| | | } |
| | | } |
| | | |
| | | func refreshApps() { |
| | | func refreshApps(forceLayoutRefresh: Bool = false) { |
| | | DispatchQueue.global(qos: .userInitiated).async { |
| | | var apps = AppIndexer.scan() |
| | | let store = TagDatabase.load() |
| | |
| | | allApps = apps |
| | | tagColors = colors |
| | | draggedTagNames = order |
| | | if forceLayoutRefresh { |
| | | withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { |
| | | layoutRefreshID = UUID() |
| | | } |
| | | finishDropRefreshAfterMinimumDuration() |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | func openApp(_ app: AppInfo) { |
| | | appDragModeActive = false |
| | | endTagNavReorder() |
| | | hideOverlay() |
| | | if let bundleIdentifier = app.bundleIdentifier { |
| | | NSWorkspace.shared.launchApplication( |
| | | withBundleIdentifier: bundleIdentifier, |
| | | options: [], |
| | | additionalEventParamDescriptor: nil, |
| | | launchIdentifier: nil |
| | | ) |
| | | return |
| | | } |
| | | |
| | | let configuration = NSWorkspace.OpenConfiguration() |
| | | NSWorkspace.shared.openApplication(at: app.path, configuration: configuration) |
| | | } |
| | |
| | | |
| | | // MARK: - Tag Label |
| | | |
| | | private struct TagReorderFramePreferenceKey: PreferenceKey { |
| | | static var defaultValue: [String: CGRect] = [:] |
| | | |
| | | static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { |
| | | value.merge(nextValue(), uniquingKeysWith: { _, new in new }) |
| | | } |
| | | } |
| | | |
| | | private struct TagNavReorderFramePreferenceKey: PreferenceKey { |
| | | static var defaultValue: [String: CGRect] = [:] |
| | | |
| | | 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 |