| | |
| | | import SwiftUI |
| | | import AppKit |
| | | import UniformTypeIdentifiers |
| | | |
| | | // MARK: - Notification for manual re-index |
| | | |
| | |
| | | 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 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" |
| | |
| | | editTagsView |
| | | case .editingApps: |
| | | editAppsView |
| | | } |
| | | |
| | | if let message = dropWarningToast { |
| | | Text(message) |
| | | .font(.system(size: 16, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .padding(.horizontal, 22) |
| | | .padding(.vertical, 12) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 10) |
| | | .fill(.ultraThickMaterial) |
| | | .shadow(color: .black.opacity(0.22), radius: 18, y: 10) |
| | | ) |
| | | .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 { |
| | |
| | | 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 { |
| | |
| | | onSelectApp: { app in openApp(app) }, |
| | | tagFontSize: tagFontSize, |
| | | iconSize: iconSize, |
| | | showNames: !hideAppNames |
| | | showNames: !hideAppNames, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onDropApp: { path, source, copy in |
| | | dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy) |
| | | } |
| | | ).id(group.id) |
| | | } |
| | | }.padding(20) |
| | |
| | | |
| | | 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 |
| | | ) { |
| | | ForEach(group.apps) { app in |
| | | AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) }) |
| | | AppGridItem( |
| | | app: app, |
| | | iconSize: iconSize, |
| | | showName: !hideAppNames, |
| | | sourceTag: group.name, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | } |
| | | } |
| | | } |
| | |
| | | RoundedRectangle(cornerRadius: 14) |
| | | .stroke(Color.primary.opacity(0.08), lineWidth: 1) |
| | | ) |
| | | .overlay { |
| | | AppDropTargetView(targetTag: group.name) { path, source, copy in |
| | | dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy) |
| | | } |
| | | .allowsHitTesting(false) |
| | | } |
| | | .shadow(color: .black.opacity((isColored && isHovered) || isColorlessActive ? 0.22 : 0), |
| | | radius: (isColored && isHovered) || isColorlessActive ? 18 : 0, |
| | | y: (isColored && isHovered) || isColorlessActive ? 10 : 0) |
| | |
| | | if isColorless { |
| | | toggleColorlessFill(group.name) |
| | | } |
| | | } |
| | | .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in |
| | | handleAppDrop(providers, targetTag: group.name) |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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)) |
| | | } |
| | | |
| | |
| | | ForEach(rows.indices, id: \.self) { rowIndex in |
| | | HStack(alignment: .top, spacing: 6) { |
| | | ForEach(rows[rowIndex]) { app in |
| | | AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) }) |
| | | AppGridItem( |
| | | app: app, |
| | | iconSize: iconSize, |
| | | showName: !hideAppNames, |
| | | sourceTag: group.name, |
| | | dragModeActive: appDragModeActive, |
| | | onDragModeChange: { setAppDragMode($0) }, |
| | | onSelect: { openApp(app) } |
| | | ) |
| | | .frame(width: cellWidth) |
| | | .frame(height: cellHeight) |
| | | } |
| | |
| | | RoundedRectangle(cornerRadius: 14) |
| | | .stroke(Color.primary.opacity(0.08), lineWidth: 1) |
| | | ) |
| | | .overlay { |
| | | AppDropTargetView(targetTag: group.name) { path, source, copy in |
| | | dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy) |
| | | } |
| | | .allowsHitTesting(false) |
| | | } |
| | | .shadow(color: .black.opacity((isColored && isHovered) || isColorlessGridActive ? 0.22 : 0), |
| | | radius: (isColored && isHovered) || isColorlessGridActive ? 18 : 0, |
| | | y: (isColored && isHovered) || isColorlessGridActive ? 10 : 0) |
| | |
| | | toggleColorlessFill(group.name) |
| | | } |
| | | } |
| | | .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in |
| | | handleAppDrop(providers, targetTag: group.name) |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | |
| | | func refreshApps() { |
| | | 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 |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in |
| | | let text: String? |
| | | if let data = item as? Data { |
| | | text = String(data: data, encoding: .utf8) |
| | | } else if let string = item as? String { |
| | | text = string |
| | | } else if let string = item as? NSString { |
| | | text = string as String |
| | | } else { |
| | | text = nil |
| | | } |
| | | guard let text else { return } |
| | | let parts = text.components(separatedBy: "\n") |
| | | guard let path = parts.first, !path.isEmpty else { return } |
| | | let source = parts.dropFirst().first ?? "" |
| | | let copy = NSEvent.modifierFlags.contains(.option) |
| | | DispatchQueue.main.async { |
| | | dropApp(path: path, sourceTag: source, targetTag: targetTag, copy: copy) |
| | | } |
| | | } |
| | | return true |
| | | } |
| | | |
| | | private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) { |
| | | appDragModeActive = false |
| | | guard !isSystemDefaultDropTarget(targetTag) else { |
| | | showDropWarning() |
| | | return |
| | | } |
| | | guard tagColors[targetTag] != nil else { return } |
| | | guard sourceTag != targetTag || copy else { return } |
| | | TagEditor.moveApp( |
| | | path: path, |
| | | from: sourceTag, |
| | | to: targetTag, |
| | | color: tagColors[targetTag] ?? 0, |
| | | copy: copy |
| | | ) |
| | | showDropRefresh() |
| | | refreshApps(forceLayoutRefresh: true) |
| | | } |
| | | |
| | | private func isSystemDefaultDropTarget(_ targetTag: String) -> Bool { |
| | | let defaultNames = [ |
| | | defaultGroupName, |
| | | tr("group.uncategorized"), |
| | | "Mac自带", |
| | | tr("group.appleBuiltIn") |
| | | ] |
| | | return defaultNames.contains(targetTag) |
| | | } |
| | | |
| | | private func showDropWarning() { |
| | | withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { |
| | | dropWarningToast = tr("drop.systemDefaultWarning") |
| | | } |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { |
| | | withAnimation(.easeOut(duration: 0.18)) { |
| | | dropWarningToast = nil |
| | | } |
| | | } |
| | | } |
| | | |
| | | 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) { |
| | | appDragModeActive = active |
| | | if active { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 8) { |
| | | if appDragModeActive { |
| | | appDragModeActive = false |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | 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 |