From e1226e6ae5a75388fe5d1a0e1ed008f80fee25ed Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 17 May 2026 14:21:25 +0800
Subject: [PATCH] Release Apptag 6.0.5 build 610
---
Apptag/ContentView.swift | 1085 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 files changed, 984 insertions(+), 101 deletions(-)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 8962e5a..7b6037e 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
+import UniformTypeIdentifiers
// MARK: - Notification for manual re-index
@@ -13,6 +14,11 @@
case none
case editingTags
case editingApps
+}
+
+enum EditTagOperation {
+ case add
+ case remove
}
// MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
@@ -109,7 +115,10 @@
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))
@@ -123,16 +132,28 @@
}
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)
}
}
@@ -141,7 +162,10 @@
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))
@@ -151,16 +175,28 @@
}
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)
}
}
@@ -189,6 +225,64 @@
// MARK: - Full-Screen Overlay
+private struct AppBubbleContext {
+ let app: AppInfo
+ let frame: CGRect
+}
+
+private struct EditActionFeedback: Identifiable {
+ let id = UUID()
+ let title: String
+ let message: String
+}
+
+private struct EditActionFeedbackBubble: View {
+ let title: String
+ let message: String
+ let onClose: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .top, spacing: 12) {
+ Text(title)
+ .font(.system(size: 24, weight: .bold))
+ .foregroundStyle(.white)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Button(action: onClose) {
+ Image(systemName: "xmark")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundStyle(.white.opacity(0.82))
+ .frame(width: 26, height: 26)
+ .background(
+ Circle()
+ .fill(Color.white.opacity(0.12))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ ScrollView(.vertical, showsIndicators: true) {
+ Text(message)
+ .font(.system(size: 15, weight: .medium))
+ .lineSpacing(3)
+ .foregroundStyle(.white.opacity(0.84))
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .frame(maxHeight: 220)
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 20)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color.black.opacity(0.92))
+ .shadow(color: .black.opacity(0.34), radius: 24, y: 16)
+ .shadow(color: .black.opacity(0.18), radius: 8, y: 3)
+ )
+ }
+}
+
struct ContentView: View {
let hideOverlay: () -> Void
@@ -200,12 +294,27 @@
@State private var editPhase: EditPhase = .none
@State private var selectedAppPaths: Set<URL> = []
@State private var selectedTagNames: Set<String> = []
- @State private var successToast: String? = nil
+ @State private var removableTagNames: Set<String> = []
+ @State private var editTagOperation: EditTagOperation = .add
+ @State private var editActionFeedback: EditActionFeedback? = 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()
+ @State private var hoveredBubble: AppBubbleContext? = nil
+ @State private var editingBubble: AppBubbleContext? = nil
+ @State private var bubbleDraftNote = ""
+ @FocusState private var bubbleNoteFocused: Bool
// Configurable defaults
@AppStorage("defaultGroupName") private var defaultGroupName = "Other"
@@ -215,6 +324,9 @@
@State private var notchHeight: CGFloat = 0
@AppStorage("displayMode") private var displayMode = "flat"
@AppStorage("hideAppNames") private var hideAppNames = false
+
+ private let editSidebarWidth: CGFloat = 188
+ private let editSidebarHorizontalInset: CGFloat = 12
private var isSideLayout: Bool {
tagPosition == "left" || tagPosition == "right"
@@ -252,6 +364,44 @@
case .editingApps:
editAppsView
}
+
+ uncommonAppBubbleOverlay
+ editActionFeedbackOverlay
+
+ 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 {
let mousePoint = NSEvent.mouseLocation
@@ -266,11 +416,17 @@
}
.onChange(of: editPhase) { _, newPhase in
let active = newPhase != .none
+ dismissAppBubble()
NotificationCenter.default.post(
name: .tagLauncherEditModeChanged,
object: nil,
userInfo: ["active": active]
)
+ }
+ .onChange(of: bubbleNoteFocused) { _, focused in
+ if editingBubble != nil && !focused {
+ commitBubbleNote()
+ }
}
}
@@ -315,6 +471,7 @@
.padding(.top, notchHeight > 0 ? notchHeight + 10 : 20)
.padding(.trailing, 20)
.keyboardShortcut("e", modifiers: .control)
+ .zIndex(2)
}
}
@@ -346,12 +503,17 @@
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)
@@ -359,7 +521,12 @@
}
}
}
- }.padding(.horizontal, 24)
+ }
+ .padding(.horizontal, 24)
+ .coordinateSpace(name: "tagNavReorder")
+ .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in
+ tagNavReorderFrames = frames
+ }
}
}
@@ -368,12 +535,17 @@
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)
@@ -381,7 +553,12 @@
}
}
}
- }.padding(12)
+ }
+ .padding(12)
+ .coordinateSpace(name: "tagNavReorder")
+ .onPreferenceChange(TagNavReorderFramePreferenceKey.self) { frames in
+ tagNavReorderFrames = frames
+ }
}.frame(width: 135)
}
@@ -403,6 +580,44 @@
flatGrid
}
}
+ .id(layoutRefreshID)
+ }
+
+ private var uncommonAppBubbleOverlay: some View {
+ GeometryReader { proxy in
+ if let context = editingBubble ?? hoveredBubble {
+ let rootFrame = proxy.frame(in: .global)
+ let editing = 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(
+ for: context,
+ rootFrame: rootFrame,
+ rootSize: proxy.size,
+ width: width,
+ placement: placement,
+ isEditing: editing
+ )
+
+ AppNameBubble(
+ appName: context.app.name,
+ note: currentNote(for: context.app),
+ isEditing: editing,
+ placement: placement,
+ draftNote: $bubbleDraftNote,
+ noteFocused: $bubbleNoteFocused,
+ onCommit: commitBubbleNote,
+ onCancel: dismissAppBubble
+ )
+ .frame(width: width)
+ .fixedSize(horizontal: false, vertical: true)
+ .position(x: metrics.centerX, y: metrics.centerY)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ .zIndex(500)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(editingBubble != nil)
}
private var flatGrid: some View {
@@ -415,7 +630,14 @@
onSelectApp: { app in openApp(app) },
tagFontSize: tagFontSize,
iconSize: iconSize,
- showNames: !hideAppNames
+ showNames: !hideAppNames,
+ dragModeActive: appDragModeActive,
+ onDragModeChange: { setAppDragMode($0) },
+ onBubbleHover: handleBubbleHover,
+ onEditNote: beginEditingBubbleNote,
+ onDropApp: { path, source, copy in
+ dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
+ }
).id(group.id)
}
}.padding(20)
@@ -471,10 +693,12 @@
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 {
@@ -498,13 +722,23 @@
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) },
+ onBubbleHover: handleBubbleHover,
+ onEditNote: beginEditingBubbleNote,
+ onSelect: { openApp(app) }
+ )
}
}
}
@@ -522,9 +756,16 @@
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)
+ .zIndex(isHovered ? 50 : 0)
.scaleEffect(isColored && isHovered ? 1.015 : 1.0)
.animation(.spring(response: 0.25, dampingFraction: 0.82), value: isHovered)
.animation(.spring(response: 0.25, dampingFraction: 0.82), value: isColorlessFilled)
@@ -538,6 +779,9 @@
if isColorless {
toggleColorlessFill(group.name)
}
+ }
+ .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in
+ handleAppDrop(providers, targetTag: group.name)
}
}
@@ -574,7 +818,7 @@
}
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
@@ -678,7 +922,7 @@
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))
}
@@ -721,7 +965,17 @@
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) },
+ onBubbleHover: handleBubbleHover,
+ onEditNote: beginEditingBubbleNote,
+ onSelect: { openApp(app) }
+ )
.frame(width: cellWidth)
.frame(height: cellHeight)
}
@@ -751,9 +1005,16 @@
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)
+ .zIndex(isHovered ? 50 : 0)
.scaleEffect(isColored && isHovered ? 1.015 : 1.0)
.animation(.spring(response: 0.25, dampingFraction: 0.82), value: isHovered)
.animation(.spring(response: 0.25, dampingFraction: 0.82), value: isColorlessFilled)
@@ -770,10 +1031,13 @@
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]] {
@@ -821,18 +1085,26 @@
private var editAppsView: some View {
VStack(spacing: 0) {
- HStack {
- Button { cancelEditApps(); setEditPhase(.none) } label: {
- Label(tr("edit.exit"), systemImage: "rectangle.portrait.and.arrow.right")
- .font(.system(size: 12))
+ HStack(alignment: .center, spacing: 14) {
+ HStack(alignment: .center, spacing: 12) {
+ Button { cancelEditApps(); setEditPhase(.none) } label: {
+ Label(tr("edit.exit"), systemImage: "rectangle.portrait.and.arrow.right")
+ .font(.system(size: 12))
+ }
+ .buttonStyle(.bordered)
+
+ operationHeaderPicker
}
- .buttonStyle(.bordered)
- Spacer()
- Text(tr("edit.title")).font(.headline)
- Spacer()
- Button(tr("edit.confirm")) { confirmAssign() }
+
+ Spacer(minLength: 16)
+
+ headerModeHintView
+
+ Button(editConfirmTitle) { confirmAssign() }
.buttonStyle(.borderedProminent)
- .disabled(selectedAppPaths.isEmpty || selectedTagNames.isEmpty)
+ .disabled(selectedAppPaths.isEmpty || pendingOperationTagNames.isEmpty)
+ .fixedSize(horizontal: true, vertical: false)
+ .layoutPriority(2)
}
.padding(.horizontal, 24)
.padding(.top, notchHeight > 0 ? notchHeight + 10 : 20)
@@ -842,34 +1114,55 @@
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 4) {
- Text(tr("edit.selectTags")).font(.caption).foregroundStyle(.secondary).padding(.bottom, 4)
- Text(tr("edit.dragHint")).font(.caption2).foregroundStyle(.tertiary).padding(.bottom, 2)
+ VStack(alignment: .leading, spacing: 6) {
+ Text(tr("edit.selectTags"))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .fixedSize(horizontal: false, vertical: true)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(tr("edit.dragHint"))
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.horizontal, editSidebarHorizontalInset)
+ .padding(.top, 4)
+ .padding(.bottom, 4)
+ .frame(width: editSidebarWidth, alignment: .leading)
+
ScrollView(.vertical, showsIndicators: false) {
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(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
+ .frame(width: editSidebarWidth, alignment: .topLeading)
Rectangle().fill(.secondary.opacity(0.12)).frame(width: 1)
if allApps.isEmpty {
@@ -879,29 +1172,19 @@
}
}
- if let msg = successToast {
- Text(msg).font(.headline)
- .padding(.horizontal, 20).padding(.vertical, 10)
- .background(RoundedRectangle(cornerRadius: 10).fill(.ultraThickMaterial))
- .transition(.move(edge: .bottom).combined(with: .opacity))
- .onAppear {
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
- withAnimation { successToast = nil }
- }
- }
- }
}
}
private var editAppsGrid: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
- ForEach(groups) { group in
+ ForEach(editGroups) { group in
editFlatGroup(group)
}
}
.padding(20)
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func editFlatGroup(_ group: TagGroup) -> some View {
@@ -917,52 +1200,144 @@
.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) }
}
+ .frame(minHeight: group.apps.isEmpty ? max(52, iconSize + 12) : 0)
}
}
private func selectableTagItem(_ tagName: String) -> some View {
let isSelected = selectedTagNames.contains(tagName)
+ let isRemovableCandidate = removableTagNames.contains(tagName)
+ let isEnabled = editTagOperation == .add || isRemovableCandidate
+ let showsPendingRemoval = editTagOperation == .remove && isRemovableCandidate && !isSelected
+ let isUncommon = tagName == TagDatabase.uncommonTagKey
+ let colorIndex = isUncommon ? 0 : (tagColors[tagName] ?? 0)
return HStack(spacing: 6) {
Circle()
- .fill(isSelected ? Color.accentColor : Color.secondary.opacity(0.3))
+ .fill(isSelected ? Color.accentColor : Color.secondary.opacity(isEnabled ? 0.3 : 0.18))
.frame(width: 16, height: 16)
.overlay(isSelected ? Image(systemName: "checkmark").font(.system(size: 8, weight: .bold)).foregroundStyle(.white) : nil)
- Text(tagName).font(.system(size: 13, weight: .medium)).foregroundStyle(.primary)
+ Text(displayTagName(tagName))
+ .font(.system(size: 13, weight: isUncommon ? .semibold : .medium))
+ .foregroundStyle(isEnabled ? Color.primary : Color.secondary.opacity(0.66))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ HStack(spacing: 4) {
+ if isUncommon {
+ Image(systemName: "questionmark.bubble.fill")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(isEnabled ? Color.secondary : Color.secondary.opacity(0.55))
+ .frame(width: 16, height: 16)
+ }
+ if showsPendingRemoval {
+ Image(systemName: "trash.fill")
+ .font(.system(size: 11, weight: .bold))
+ .foregroundStyle(.red)
+ .frame(width: 16, height: 16)
+ .transition(.scale(scale: 0.85).combined(with: .opacity))
+ } else if editTagOperation == .remove {
+ Color.clear.frame(width: 16, height: 16)
+ }
+ }
+ .frame(
+ minWidth: editTagOperation == .remove
+ ? (isUncommon ? 36 : 16)
+ : (isUncommon ? 16 : 0),
+ alignment: .trailing
+ )
+ .layoutPriority(1)
}
- .padding(.horizontal, 10).padding(.vertical, 5)
+ .padding(.horizontal, 10)
+ .frame(height: 27)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 6)
- .fill(Color(nsColor: TagColor.nsColor(for: tagColors[tagName] ?? 0).withAlphaComponent(0.3))))
+ .fill(Color(nsColor: TagColor.nsColor(for: colorIndex).withAlphaComponent(isEnabled ? (isUncommon ? 0.22 : 0.3) : 0.12))))
.contentShape(RoundedRectangle(cornerRadius: 6))
.onTapGesture {
+ guard isEnabled else { return }
if isSelected { selectedTagNames.remove(tagName) } else { selectedTagNames.insert(tagName) }
}
+ .opacity(isEnabled ? 1.0 : 0.58)
+ .animation(.easeInOut(duration: 0.15), value: showsPendingRemoval)
+ }
+
+ private func tagReorderGesture(for tagName: String) -> some Gesture {
+ DragGesture(minimumDistance: 4, coordinateSpace: .named("tagReorderList"))
+ .onChanged { value in
+ guard canReorderTag(tagName) else { return }
+ if dragItem == nil {
+ dragItem = tagName
+ }
+ guard dragItem == tagName else { return }
+ reorderDraggedTag(at: value.location)
+ }
+ .onEnded { _ in
+ guard canReorderTag(tagName) else { return }
+ 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, !selectedTagNames.isEmpty else { return }
- let paths = selectedAppPaths.map { $0.path }
- for tagName in selectedTagNames {
- TagEditor.assignTag(tagName, color: tagColors[tagName] ?? 0, to: paths)
+ let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) }
+ let tags = pendingOperationTagNames
+ guard !selectedApps.isEmpty, !tags.isEmpty else { return }
+
+ let paths = selectedApps.map { $0.path.path }
+ let feedback = buildEditActionFeedback(for: selectedApps, tags: tags)
+
+ switch editTagOperation {
+ case .add:
+ TagEditor.appendTags(tags, to: paths)
+ case .remove:
+ TagEditor.removeTags(tags, from: paths)
}
- selectedAppPaths = []; selectedTagNames = []
+ selectedAppPaths = []
+ selectedTagNames = []
+ removableTagNames = []
refreshApps()
- withAnimation { successToast = tr("edit.success") }
+ showEditActionFeedback(feedback)
}
private func cancelEditApps() {
- selectedAppPaths = []; selectedTagNames = []
+ selectedAppPaths = []
+ selectedTagNames = []
+ removableTagNames = []
+ editTagOperation = .add
}
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) {
@@ -984,6 +1359,232 @@
.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() {
+ guard !selectedAppPaths.isEmpty else {
+ selectedTagNames = []
+ removableTagNames = []
+ return
+ }
+
+ guard editTagOperation == .remove else {
+ removableTagNames = []
+ return
+ }
+
+ let selectedApps = allApps.filter { selectedAppPaths.contains($0.path) }
+ let autoSelectedTags = selectedApps.reduce(into: Set<String>()) { result, app in
+ result.formUnion(app.tags)
+ if app.isUncommon {
+ result.insert(TagDatabase.uncommonTagKey)
+ }
+ }
+ removableTagNames = autoSelectedTags
+ selectedTagNames = autoSelectedTags
+ }
+
+ private var operationHeaderPicker: some View {
+ HStack(spacing: 8) {
+ Text(tr("edit.operationLabel"))
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 6) {
+ operationModeButton(.add, titleKey: "edit.modeAdd", systemImage: "plus.circle.fill")
+ operationModeButton(.remove, titleKey: "edit.modeRemove", systemImage: "minus.circle.fill")
+ }
+ }
+ }
+
+ private func operationModeButton(_ mode: EditTagOperation, titleKey: String, systemImage: String) -> some View {
+ let isActive = editTagOperation == mode
+ return Button {
+ setEditTagOperation(mode)
+ } label: {
+ Label(tr(titleKey), systemImage: systemImage)
+ .font(.system(size: 12, weight: .semibold))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .foregroundStyle(isActive ? Color.white : Color.primary)
+ .background(
+ Capsule(style: .continuous)
+ .fill(isActive ? Color.accentColor : Color.secondary.opacity(0.12))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var editConfirmTitle: String {
+ switch editTagOperation {
+ case .add:
+ return tr("edit.confirmAdd")
+ case .remove:
+ return tr("edit.confirmRemove")
+ }
+ }
+
+ private func setEditTagOperation(_ mode: EditTagOperation) {
+ guard editTagOperation != mode else { return }
+ editTagOperation = mode
+ if mode == .add {
+ selectedTagNames = []
+ removableTagNames = []
+ } else {
+ syncSelectedTagsFromSelectedApps()
+ }
+ }
+
+ private var pendingOperationTagNames: [String] {
+ switch editTagOperation {
+ case .add:
+ return sortedTagNames.filter { selectedTagNames.contains($0) }
+ case .remove:
+ return sortedTagNames.filter { removableTagNames.contains($0) && !selectedTagNames.contains($0) }
+ }
+ }
+
+ private var editModeHintText: String {
+ editTagOperation == .remove ? tr("edit.removeHint") : tr("edit.modeHint")
+ }
+
+ private var headerModeHintView: some View {
+ Text(editModeHintText)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .frame(minWidth: 0, maxWidth: 760, alignment: .trailing)
+ .layoutPriority(1)
+ .help(editModeHintText)
+ }
+
+ private var editActionFeedbackOverlay: some View {
+ GeometryReader { proxy in
+ if let feedback = editActionFeedback {
+ ZStack {
+ Color.black.opacity(0.08)
+ .ignoresSafeArea()
+
+ EditActionFeedbackBubble(
+ title: feedback.title,
+ message: feedback.message,
+ onClose: dismissEditActionFeedback
+ )
+ .frame(width: min(760, max(360, proxy.size.width - 120)))
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ }
+ .zIndex(700)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(editActionFeedback != nil)
+ }
+
+ private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
+ let appNames = selectedApps.map(\.name)
+ .joined(separator: localizedListSeparator)
+
+ switch editTagOperation {
+ case .add:
+ let actualAddCount = selectedApps.reduce(into: 0) { count, app in
+ count += tags.reduce(into: 0) { subtotal, tagName in
+ if !appHasTag(app, tagName: tagName) {
+ subtotal += 1
+ }
+ }
+ }
+
+ return EditActionFeedback(
+ title: tr("edit.confirmAdd"),
+ message: formattedFeedbackMessage(
+ forKey: "edit.feedbackAddFormat",
+ replacements: [
+ "%appCount%": "\(selectedApps.count)",
+ "%appNames%": appNames,
+ "%tagCount%": "\(actualAddCount)"
+ ]
+ )
+ )
+
+ case .remove:
+ let actualRemovedTagNames = tags.filter { tagName in
+ selectedApps.contains(where: { appHasTag($0, tagName: tagName) })
+ }
+ let actualRemoveCount = selectedApps.reduce(into: 0) { count, app in
+ count += tags.reduce(into: 0) { subtotal, tagName in
+ if appHasTag(app, tagName: tagName) {
+ subtotal += 1
+ }
+ }
+ }
+
+ return EditActionFeedback(
+ title: tr("edit.confirmRemove"),
+ message: formattedFeedbackMessage(
+ forKey: "edit.feedbackRemoveFormat",
+ replacements: [
+ "%appCount%": "\(selectedApps.count)",
+ "%appNames%": appNames,
+ "%tagCount%": "\(actualRemoveCount)",
+ "%tagNames%": actualRemovedTagNames
+ .map(displayTagName)
+ .joined(separator: localizedListSeparator)
+ ]
+ )
+ )
+ }
+ }
+
+ private func appHasTag(_ app: AppInfo, tagName: String) -> Bool {
+ if tagName == TagDatabase.uncommonTagKey {
+ return app.isUncommon
+ }
+ return app.tags.contains(tagName)
+ }
+
+ private func formattedFeedbackMessage(forKey key: String, replacements: [String: String]) -> String {
+ replacements.reduce(tr(key)) { partial, replacement in
+ partial.replacingOccurrences(of: replacement.key, with: replacement.value)
+ }
+ }
+
+ private func showEditActionFeedback(_ feedback: EditActionFeedback) {
+ let feedbackID = feedback.id
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
+ editActionFeedback = feedback
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.6) {
+ guard editActionFeedback?.id == feedbackID else { return }
+ dismissEditActionFeedback()
+ }
+ }
+
+ private func dismissEditActionFeedback() {
+ withAnimation(.easeOut(duration: 0.18)) {
+ editActionFeedback = nil
+ }
+ }
+
+ private var localizedListSeparator: String {
+ switch L10n.currentCode {
+ case "zh-Hans", "zh-Hant", "ja":
+ return "、"
+ case "ar", "ar-Najdi":
+ return "، "
+ default:
+ return ", "
+ }
+ }
+
// MARK: - Computed
private var sortedTagNames: [String] {
@@ -991,7 +1592,11 @@
// Use user-defined order, fall back to alpha
let ordered = draggedTagNames.filter { filtered.contains($0) }
let remaining = filtered.filter { !ordered.contains($0) }.sorted()
- return ordered + remaining
+ return ordered + remaining + [TagDatabase.uncommonTagKey]
+ }
+
+ private func displayTagName(_ tagName: String) -> String {
+ tagName == TagDatabase.uncommonTagKey ? tr("group.uncommon") : tagName
}
private var tagLabels: [TagLabel] {
@@ -1015,7 +1620,98 @@
}
}
+ private var editGroups: [TagGroup] {
+ groups + [
+ TagGroup(
+ name: tr("group.uncommon"),
+ apps: allApps.filter(\.isUncommon).sorted {
+ $0.name.localizedStandardCompare($1.name) == .orderedAscending
+ }
+ )
+ ]
+ }
+
// MARK: - Actions
+
+ private func handleBubbleHover(app: AppInfo, frame: CGRect, hovering: Bool) {
+ guard editingBubble == nil else { return }
+ if hovering {
+ hoveredBubble = AppBubbleContext(app: app, frame: frame)
+ } else if hoveredBubble?.app.path == app.path {
+ hoveredBubble = nil
+ }
+ }
+
+ private func beginEditingBubbleNote(app: AppInfo, frame: CGRect) {
+ bubbleDraftNote = currentNote(for: app)
+ hoveredBubble = nil
+ editingBubble = AppBubbleContext(app: app, frame: frame)
+ DispatchQueue.main.async {
+ bubbleNoteFocused = true
+ }
+ }
+
+ private func commitBubbleNote() {
+ guard let context = editingBubble else { return }
+ let limited = String(bubbleDraftNote.prefix(TagDatabase.maxAppNoteLength))
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ TagEditor.setAppNote(limited, for: context.app.path.path)
+ bubbleDraftNote = limited
+ editingBubble = nil
+ bubbleNoteFocused = false
+ refreshApps()
+ }
+
+ private func dismissAppBubble() {
+ hoveredBubble = nil
+ editingBubble = nil
+ bubbleNoteFocused = false
+ }
+
+ private func currentNote(for app: AppInfo) -> String {
+ if let latest = allApps.first(where: { $0.path == app.path })?.note {
+ return latest
+ }
+ return app.note ?? ""
+ }
+
+ private func bubblePlacement(for frame: CGRect, rootFrame: CGRect) -> BubblePlacement {
+ frame.minY - rootFrame.minY < 170 ? .below : .above
+ }
+
+ private func bubbleMetrics(
+ for context: AppBubbleContext,
+ rootFrame: CGRect,
+ rootSize: CGSize,
+ width: CGFloat,
+ placement: BubblePlacement,
+ isEditing: Bool
+ ) -> (centerX: CGFloat, centerY: CGFloat) {
+ let appCenterX = context.frame.midX - rootFrame.minX
+ let halfWidth = width / 2
+ let centerX = min(max(appCenterX, halfWidth + 24), max(halfWidth + 24, rootSize.width - halfWidth - 24))
+ let estimatedHeight = estimatedBubbleHeight(for: context.app, width: width, isEditing: isEditing)
+ let topY: CGFloat
+ if placement == .below {
+ topY = context.frame.maxY - rootFrame.minY + 10
+ } else {
+ topY = context.frame.minY - rootFrame.minY - estimatedHeight - 10
+ }
+ let clampedTopY = min(max(topY, 14), max(14, rootSize.height - estimatedHeight - 14))
+ return (centerX, clampedTopY + estimatedHeight / 2)
+ }
+
+ private func estimatedBubbleHeight(for app: AppInfo, width: CGFloat, isEditing: Bool) -> CGFloat {
+ if isEditing {
+ return 120
+ }
+ let note = currentNote(for: app).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !note.isEmpty else { return 68 }
+ let contentWidth = max(160, width - 48)
+ let approxCharsPerLine = max(16, Int(contentWidth / 14))
+ let lines = min(4, max(1, Int(ceil(Double(note.count) / Double(approxCharsPerLine)))))
+ return 70 + CGFloat(lines) * 20
+ }
func scrollTo(_ id: String) {
withAnimation(.easeInOut(duration: 0.25)) { scrollProxy?.scrollTo(id, anchor: .top) }
@@ -1031,33 +1727,204 @@
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 .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) {
+ if active {
+ endTagNavReorder()
+ }
+ 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()
- apps = TagEditor.annotate(apps: apps)
+ let scannedApps = AppIndexer.scan()
+ let store = TagEditor.reconcileScannedApps(scannedApps)
+ let apps = TagEditor.annotate(apps: scannedApps, store: store)
let colors = store.tags.mapValues { $0.color }
let order = TagEditor.orderedTagNames()
DispatchQueue.main.async {
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
+ DispatchQueue.global(qos: .utility).async {
+ TagEditor.recordLauncherOpen(for: app.path.path)
}
-
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: app.path, configuration: configuration)
}
@@ -1065,6 +1932,22 @@
// 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
--
Gitblit v1.9.3