From 2f5a78879bde7e817f1d3a71864685f0269ccb07 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 18 May 2026 17:17:41 +0800
Subject: [PATCH] Hide app bubbles during drag decisions
---
Apptag/ContentView.swift | 107 ++++++++++++++++++++++++++++++++++++++++++++++-------
1 files changed, 92 insertions(+), 15 deletions(-)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 7cac627..8eae143 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -324,6 +324,12 @@
let message: String
}
+private struct PendingUncategorizedDrop: Identifiable {
+ let id = UUID()
+ let app: AppInfo
+ let assignedTags: [String]
+}
+
private enum SmartStartNoticeMode {
case autoApplied
case suggestionOnly
@@ -370,8 +376,10 @@
@State private var refreshInProgress = false
@State private var refreshAgainAfterCurrent = false
@State private var refreshAgainForceLayout = false
+ @State private var hoveredAppItemID: String? = nil
@State private var hoveredBubble: AppBubbleContext? = nil
@State private var editingBubble: AppBubbleContext? = nil
+ @State private var pendingUncategorizedDrop: PendingUncategorizedDrop? = nil
@State private var bubbleDraftNote = ""
@FocusState private var bubbleNoteFocused: Bool
@@ -388,6 +396,9 @@
private let editSidebarHorizontalInset: CGFloat = 12
private let floatingControlsTrailingInset: CGFloat = 20
private let floatingControlsReservedWidth: CGFloat = 120
+ private var appBubbleDisabled: Bool {
+ appDragModeActive || pendingUncategorizedDrop != nil
+ }
private let rightSidebarFloatingClearance: CGFloat = 44
private var isSideLayout: Bool {
@@ -434,6 +445,7 @@
uncommonAppBubbleOverlay
smartStartNoticeOverlay
editActionFeedbackOverlay
+ uncategorizedDropConfirmOverlay
if let message = dropWarningToast {
Text(message)
@@ -825,6 +837,8 @@
onDragModeChange: { setAppDragMode($0) },
onBubbleHover: handleBubbleHover,
onEditNote: beginEditingBubbleNote,
+ bubbleDisabled: appBubbleDisabled,
+ hoveredAppItemID: $hoveredAppItemID,
onDropApp: { path, source, copy in
dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
}
@@ -927,6 +941,9 @@
onDragModeChange: { setAppDragMode($0) },
onBubbleHover: handleBubbleHover,
onEditNote: beginEditingBubbleNote,
+ bubbleDisabled: appBubbleDisabled,
+ itemID: "\(group.name)|\(app.path.path)",
+ hoveredAppItemID: $hoveredAppItemID,
onSelect: { openApp(app) }
)
}
@@ -1163,6 +1180,9 @@
onDragModeChange: { setAppDragMode($0) },
onBubbleHover: handleBubbleHover,
onEditNote: beginEditingBubbleNote,
+ bubbleDisabled: appBubbleDisabled,
+ itemID: "\(group.name)|\(app.path.path)",
+ hoveredAppItemID: $hoveredAppItemID,
onSelect: { openApp(app) }
)
.frame(width: cellWidth)
@@ -1556,6 +1576,32 @@
.allowsHitTesting(editActionFeedback != nil)
}
+ private var uncategorizedDropConfirmOverlay: some View {
+ GeometryReader { proxy in
+ if let pendingDrop = pendingUncategorizedDrop {
+ ZStack {
+ Color.black.opacity(0.14)
+ .ignoresSafeArea()
+
+ UncategorizedDropConfirmBubble(
+ title: tr("drop.uncategorizedConfirmTitle"),
+ message: uncategorizedConfirmMessage(for: pendingDrop),
+ cancelTitle: tr("tag.cancel"),
+ confirmTitle: tr("edit.confirm"),
+ onCancel: dismissUncategorizedDropConfirm,
+ onConfirm: confirmPendingUncategorizedDrop
+ )
+ .frame(width: min(540, max(340, proxy.size.width - 120)))
+ .position(x: proxy.size.width / 2, y: proxy.size.height / 2)
+ .transition(.scale(scale: 0.94).combined(with: .opacity))
+ }
+ .zIndex(710)
+ }
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(pendingUncategorizedDrop != nil)
+ }
+
private func buildEditActionFeedback(for selectedApps: [AppInfo], tags: [String]) -> EditActionFeedback {
let appNames = selectedApps.map(\.name)
.joined(separator: localizedListSeparator)
@@ -1808,6 +1854,10 @@
// MARK: - Actions
private func handleBubbleHover(app: AppInfo, frame: CGRect, hovering: Bool) {
+ guard !appBubbleDisabled else {
+ clearAppBubbleState()
+ return
+ }
guard editingBubble == nil else { return }
if hovering {
hoveredBubble = AppBubbleContext(app: app, frame: frame)
@@ -1817,6 +1867,10 @@
}
private func beginEditingBubbleNote(app: AppInfo, frame: CGRect) {
+ guard !appBubbleDisabled else {
+ clearAppBubbleState()
+ return
+ }
bubbleDraftNote = currentNote(for: app)
hoveredBubble = nil
editingBubble = AppBubbleContext(app: app, frame: frame)
@@ -1839,6 +1893,16 @@
}
private func dismissAppBubble() {
+ hoveredBubble = nil
+ if editingBubble != nil {
+ notifyAppNoteEditing(active: false)
+ }
+ editingBubble = nil
+ bubbleNoteFocused = false
+ }
+
+ private func clearAppBubbleState() {
+ hoveredAppItemID = nil
hoveredBubble = nil
if editingBubble != nil {
notifyAppNoteEditing(active: false)
@@ -2048,11 +2112,11 @@
guard let app = allApps.first(where: { $0.path.path == path }) else { return }
let assignedTags = assignedRegularDisplayTags(for: app)
guard !assignedTags.isEmpty else { return }
- guard confirmUncategorizedDrop(appName: app.name, assignedTags: assignedTags) else { return }
- TagEditor.removeTags(app.tags, from: [path])
- showDropRefresh()
- refreshApps(forceLayoutRefresh: true)
+ clearAppBubbleState()
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.84)) {
+ pendingUncategorizedDrop = PendingUncategorizedDrop(app: app, assignedTags: assignedTags)
+ }
}
private func assignedRegularDisplayTags(for app: AppInfo) -> [String] {
@@ -2066,21 +2130,33 @@
return result
}
- private func confirmUncategorizedDrop(appName: String, assignedTags: [String]) -> Bool {
- let alert = NSAlert()
- alert.alertStyle = .warning
- alert.messageText = tr("drop.uncategorizedConfirmTitle")
- alert.informativeText = formattedFeedbackMessage(
+ private func uncategorizedConfirmMessage(for pendingDrop: PendingUncategorizedDrop) -> String {
+ formattedFeedbackMessage(
forKey: "drop.uncategorizedConfirmMessage",
replacements: [
- "%appName%": appName,
- "%tagCount%": "\(assignedTags.count)",
- "%tagNames%": assignedTags.joined(separator: localizedListSeparator)
+ "%appName%": pendingDrop.app.name,
+ "%tagCount%": "\(pendingDrop.assignedTags.count)",
+ "%tagNames%": pendingDrop.assignedTags.joined(separator: localizedListSeparator)
]
)
- alert.addButton(withTitle: tr("edit.confirm"))
- alert.addButton(withTitle: tr("tag.cancel"))
- return alert.runModal() == .alertFirstButtonReturn
+ }
+
+ private func dismissUncategorizedDropConfirm() {
+ withAnimation(.easeOut(duration: 0.18)) {
+ pendingUncategorizedDrop = nil
+ }
+ }
+
+ private func confirmPendingUncategorizedDrop() {
+ guard let pendingDrop = pendingUncategorizedDrop else { return }
+ let path = pendingDrop.app.path.path
+ let tags = pendingDrop.app.tags
+ withAnimation(.easeOut(duration: 0.16)) {
+ pendingUncategorizedDrop = nil
+ }
+ TagEditor.removeTags(tags, from: [path])
+ showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
}
private func isUncategorizedDropTarget(_ targetTag: String) -> Bool {
@@ -2132,6 +2208,7 @@
private func setAppDragMode(_ active: Bool) {
if active {
endTagNavReorder()
+ clearAppBubbleState()
}
appDragModeActive = active
if active {
--
Gitblit v1.9.3