From 554dce57269e2ae24f5de9c39972cacbc0a350dc Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 19 May 2026 03:03:21 +0800
Subject: [PATCH] Fix stale app hover bubbles
---
Apptag/ContentView.swift | 266 ++++++++++++++++++++++++++++++++++++++++------------
1 files changed, 202 insertions(+), 64 deletions(-)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index 045bb29..b9bb245 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -9,6 +9,9 @@
static let tagLauncherAppNoteEditingChanged = Notification.Name("TagLauncherAppNoteEditingChanged")
static let tagLauncherDataDidChange = Notification.Name("TagLauncherDataDidChange")
static let tagLauncherOpenPreferencesRequested = Notification.Name("TagLauncherOpenPreferencesRequested")
+ static let tagLauncherOverlayDidShow = Notification.Name("TagLauncherOverlayDidShow")
+ static let tagLauncherOverlayDidHide = Notification.Name("TagLauncherOverlayDidHide")
+ static let tagLauncherModalInteractionChanged = Notification.Name("TagLauncherModalInteractionChanged")
}
// MARK: - Edit Phase
@@ -198,8 +201,8 @@
let colorIndex: Int
var dragModeActive: Bool = false
var isDragging: Bool = false
+ var dragResetToken: Int = 0
let action: () -> Void
- @State private var wiggle = false
private var bgColor: Color {
Color(nsColor: TagColor.nsColor(for: colorIndex))
@@ -221,20 +224,14 @@
.background(RoundedRectangle(cornerRadius: 7).fill(bgColor))
.shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1)
.scaleEffect(isDragging ? 1.05 : 1.0)
- .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.8 : -1.8) : 0))
- .animation(
- dragModeActive
- ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
- : .default,
- value: wiggle
- )
- .onChange(of: dragModeActive) { _, active in
- wiggle = active
- }
+ .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
+ .animation(.easeOut(duration: 0.08), value: isDragging)
+ .animation(.easeOut(duration: 0.08), value: dragModeActive)
+ .id("\(name)|reset-\(dragResetToken)")
.contentShape(RoundedRectangle(cornerRadius: 7))
.onTapGesture {
- if !dragModeActive { action() }
- }
+ action()
+ }
}
}
@@ -245,8 +242,8 @@
let colorIndex: Int
var dragModeActive: Bool = false
var isDragging: Bool = false
+ var dragResetToken: Int = 0
let action: () -> Void
- @State private var wiggle = false
private var bgColor: Color {
Color(nsColor: TagColor.nsColor(for: colorIndex))
@@ -264,20 +261,14 @@
.background(RoundedRectangle(cornerRadius: 6).fill(bgColor))
.shadow(color: .black.opacity(isDragging ? 0.34 : 0.2), radius: isDragging ? 8 : 3, y: isDragging ? 4 : 1)
.scaleEffect(isDragging ? 1.03 : 1.0)
- .rotationEffect(.degrees(dragModeActive ? (wiggle ? 1.6 : -1.6) : 0))
- .animation(
- dragModeActive
- ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
- : .default,
- value: wiggle
- )
- .onChange(of: dragModeActive) { _, active in
- wiggle = active
- }
+ .opacity(dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0)
+ .animation(.easeOut(duration: 0.08), value: isDragging)
+ .animation(.easeOut(duration: 0.08), value: dragModeActive)
+ .id("\(name)|reset-\(dragResetToken)")
.contentShape(RoundedRectangle(cornerRadius: 6))
.onTapGesture {
- if !dragModeActive { action() }
- }
+ action()
+ }
}
}
@@ -324,6 +315,12 @@
let message: String
}
+private struct PendingUncategorizedDrop: Identifiable {
+ let id = UUID()
+ let app: AppInfo
+ let assignedTags: [String]
+}
+
private enum SmartStartNoticeMode {
case autoApplied
case suggestionOnly
@@ -357,7 +354,9 @@
@State private var tagReorderFrames: [String: CGRect] = [:]
@State private var tagNavDragModeActive = false
@State private var tagNavDragItem: String? = nil
+ @State private var tagNavDragResetToken = 0
@State private var tagNavReorderFrames: [String: CGRect] = [:]
+ @State private var tagNavReorderDidMove = false
@State private var hoveredContainer: String? = nil // colored container lift
// Fixed interaction for "Colorless Container": hover fills persistently; click clears.
@State private var filledColorlessContainer: String? = nil
@@ -373,6 +372,8 @@
@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 appDragResetToken = 0
@State private var bubbleDraftNote = ""
@FocusState private var bubbleNoteFocused: Bool
@@ -389,6 +390,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 {
@@ -435,6 +439,7 @@
uncommonAppBubbleOverlay
smartStartNoticeOverlay
editActionFeedbackOverlay
+ uncategorizedDropConfirmOverlay
if let message = dropWarningToast {
Text(message)
@@ -472,14 +477,19 @@
}
}
.onAppear {
- let mousePoint = NSEvent.mouseLocation
- let activeScreen = NSScreen.screens.first(where: {
- NSMouseInRect(mousePoint, $0.frame, false)
- }) ?? NSScreen.main
- notchHeight = activeScreen?.safeAreaInsets.top ?? 0
+ refreshNotchHeight()
refreshApps()
}
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidShow)) { _ in
+ resetTransientDragState()
+ refreshNotchHeight()
+ refreshApps()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .tagLauncherOverlayDidHide)) { _ in
+ resetTransientDragState()
+ }
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
+ guard allApps.isEmpty, !refreshInProgress else { return }
refreshApps()
}
.onReceive(NotificationCenter.default.publisher(for: .tagLauncherDataDidChange)) { _ in
@@ -507,6 +517,20 @@
if editingBubble != nil && !focused {
commitBubbleNote()
}
+ }
+ .onChange(of: pendingUncategorizedDrop != nil) { _, active in
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": active]
+ )
+ }
+ .onDisappear {
+ NotificationCenter.default.post(
+ name: .tagLauncherModalInteractionChanged,
+ object: nil,
+ userInfo: ["active": false]
+ )
}
}
@@ -600,11 +624,9 @@
TagPill(name: tag.name, colorIndex: tag.colorIndex,
dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
isDragging: tagNavDragItem == tag.name,
+ dragResetToken: tagNavDragResetToken,
action: {
- if isColorlessContainerMode {
- toggleColorlessFill(tag.id)
- }
- scrollTo(tag.id)
+ activateTagNavigation(tag.id)
})
.background(tagNavFrameReader(for: tag.name))
.zIndex(tagNavDragItem == tag.name ? 1 : 0)
@@ -644,11 +666,9 @@
SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
isDragging: tagNavDragItem == tag.name,
+ dragResetToken: tagNavDragResetToken,
action: {
- if isColorlessContainerMode {
- toggleColorlessFill(tag.id)
- }
- scrollTo(tag.id)
+ activateTagNavigation(tag.id)
})
.background(tagNavFrameReader(for: tag.name))
.zIndex(tagNavDragItem == tag.name ? 1 : 0)
@@ -826,6 +846,8 @@
onDragModeChange: { setAppDragMode($0) },
onBubbleHover: handleBubbleHover,
onEditNote: beginEditingBubbleNote,
+ bubbleDisabled: appBubbleDisabled,
+ dragResetToken: appDragResetToken,
hoveredAppItemID: $hoveredAppItemID,
onDropApp: { path, source, copy in
dropApp(path: path, sourceTag: source, targetTag: group.name, copy: copy)
@@ -929,7 +951,9 @@
onDragModeChange: { setAppDragMode($0) },
onBubbleHover: handleBubbleHover,
onEditNote: beginEditingBubbleNote,
+ bubbleDisabled: appBubbleDisabled,
itemID: "\(group.name)|\(app.path.path)",
+ dragResetToken: appDragResetToken,
hoveredAppItemID: $hoveredAppItemID,
onSelect: { openApp(app) }
)
@@ -1167,7 +1191,9 @@
onDragModeChange: { setAppDragMode($0) },
onBubbleHover: handleBubbleHover,
onEditNote: beginEditingBubbleNote,
+ bubbleDisabled: appBubbleDisabled,
itemID: "\(group.name)|\(app.path.path)",
+ dragResetToken: appDragResetToken,
hoveredAppItemID: $hoveredAppItemID,
onSelect: { openApp(app) }
)
@@ -1562,6 +1588,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)
@@ -1813,16 +1865,38 @@
// MARK: - Actions
- private func handleBubbleHover(app: AppInfo, frame: CGRect, hovering: Bool) {
+ private func refreshNotchHeight() {
+ let mousePoint = NSEvent.mouseLocation
+ let activeScreen = NSScreen.screens.first(where: {
+ NSMouseInRect(mousePoint, $0.frame, false)
+ }) ?? NSScreen.main
+ notchHeight = activeScreen?.safeAreaInsets.top ?? 0
+ }
+
+ private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
+ guard !appBubbleDisabled else {
+ clearAppBubbleState()
+ return
+ }
guard editingBubble == nil else { return }
- if hovering {
- hoveredBubble = AppBubbleContext(app: app, frame: frame)
- } else if hoveredBubble?.app.path == app.path {
+ switch event {
+ case .entered(let canShowBubble):
+ if canShowBubble {
+ hoveredBubble = AppBubbleContext(app: app, frame: frame)
+ } else {
+ hoveredBubble = nil
+ }
+ case .exited:
+ guard hoveredBubble?.app.path == app.path else { return }
hoveredBubble = nil
}
}
private func beginEditingBubbleNote(app: AppInfo, frame: CGRect) {
+ guard !appBubbleDisabled else {
+ clearAppBubbleState()
+ return
+ }
bubbleDraftNote = currentNote(for: app)
hoveredBubble = nil
editingBubble = AppBubbleContext(app: app, frame: frame)
@@ -1845,6 +1919,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)
@@ -1917,6 +2001,14 @@
withAnimation(.easeInOut(duration: 0.25)) { scrollProxy?.scrollTo(id, anchor: .top) }
}
+ private func activateTagNavigation(_ id: String) {
+ cancelTagNavReorderVisualState()
+ if isColorlessContainerMode {
+ toggleColorlessFill(id)
+ }
+ scrollTo(id)
+ }
+
private func fillColorlessContainer(_ id: String) {
guard isColorlessContainerMode else { return }
guard filledColorlessContainer != id else { return }
@@ -1944,8 +2036,8 @@
}
private func tagNavReorderGesture(for tagName: String) -> some Gesture {
- LongPressGesture(minimumDuration: 0.5)
- .sequenced(before: DragGesture(minimumDistance: 3, coordinateSpace: .named("tagNavReorder")))
+ LongPressGesture(minimumDuration: 0.35)
+ .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named("tagNavReorder")))
.onChanged { value in
guard canReorderTag(tagName) else { return }
switch value {
@@ -1971,16 +2063,27 @@
guard canReorderTag(tagName) else { return }
if tagNavDragItem == nil {
tagNavDragItem = tagName
+ tagNavReorderDidMove = false
}
tagNavDragModeActive = true
}
private func endTagNavReorder() {
- if tagNavDragModeActive {
+ if tagNavDragModeActive && tagNavReorderDidMove {
TagEditor.reorderTags(draggedTagNames)
}
tagNavDragModeActive = false
tagNavDragItem = nil
+ tagNavReorderDidMove = false
+ tagNavDragResetToken &+= 1
+ }
+
+ private func cancelTagNavReorderVisualState() {
+ guard tagNavDragModeActive || tagNavDragItem != nil else { return }
+ tagNavDragModeActive = false
+ tagNavDragItem = nil
+ tagNavReorderDidMove = false
+ tagNavDragResetToken &+= 1
}
private func reorderTagNavItem(at location: CGPoint) {
@@ -1991,6 +2094,7 @@
let toIndex = draggedTagNames.firstIndex(of: targetName)
else { return }
+ tagNavReorderDidMove = true
withAnimation(.spring(response: 0.22, dampingFraction: 0.82)) {
let destination = toIndex > fromIndex ? toIndex + 1 : toIndex
draggedTagNames.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: destination)
@@ -2025,7 +2129,7 @@
}
private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
- appDragModeActive = false
+ resetTransientDragState(keepingPendingUncategorizedDrop: true)
if isUncategorizedDropTarget(targetTag) {
confirmAndMoveAppToUncategorized(path: path)
@@ -2054,11 +2158,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] {
@@ -2072,21 +2176,35 @@
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() {
+ resetTransientDragState(keepingPendingUncategorizedDrop: true)
+ 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
+ resetTransientDragState(keepingPendingUncategorizedDrop: true)
+ withAnimation(.easeOut(duration: 0.16)) {
+ pendingUncategorizedDrop = nil
+ }
+ TagEditor.removeTags(tags, from: [path])
+ showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
}
private func isUncategorizedDropTarget(_ targetTag: String) -> Bool {
@@ -2138,6 +2256,7 @@
private func setAppDragMode(_ active: Bool) {
if active {
endTagNavReorder()
+ clearAppBubbleState()
}
appDragModeActive = active
if active {
@@ -2149,10 +2268,29 @@
}
}
+ private func resetTransientDragState(keepingPendingUncategorizedDrop: Bool = false) {
+ AppDragCoordinator.shared.cancelDrag()
+ appDragModeActive = false
+ appDragResetToken &+= 1
+ tagNavDragModeActive = false
+ tagNavDragItem = nil
+ tagNavReorderDidMove = false
+ tagNavDragResetToken &+= 1
+ dragItem = nil
+ hoveredAppItemID = nil
+ hoveredContainer = nil
+ clearAppBubbleState()
+ if !keepingPendingUncategorizedDrop {
+ pendingUncategorizedDrop = nil
+ }
+ }
+
func refreshApps(forceLayoutRefresh: Bool = false) {
guard !refreshInProgress else {
- refreshAgainAfterCurrent = true
- refreshAgainForceLayout = refreshAgainForceLayout || forceLayoutRefresh
+ if forceLayoutRefresh {
+ refreshAgainAfterCurrent = true
+ refreshAgainForceLayout = true
+ }
return
}
--
Gitblit v1.9.3