From 8478c55e513bbbb4b1676d1d9207f7feb2509465 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 22 May 2026 00:30:57 +0800
Subject: [PATCH] Freeze 7.6.0 release candidate and move docs out of repo
---
Apptag/AppGridItem.swift | 202 ++++++++++++++++++++++++++++++++++++++++++-------
1 files changed, 171 insertions(+), 31 deletions(-)
diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index 9fa97e2..c187acf 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -10,17 +10,21 @@
var sourceTag: String? = nil
var dragModeActive: Bool = false
var onDragModeChange: ((Bool) -> Void)? = nil
- var onBubbleHover: ((AppInfo, CGRect, Bool) -> Void)? = nil
+ var onBubbleHover: ((AppInfo, CGRect, AppBubbleHoverEvent) -> Void)? = nil
var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
+ var bubbleDisabled: Bool = false
+ var showUncommonAppBubbles: Bool = AppDefaults.showUncommonAppBubbles
+ var itemID: String
+ var dragResetToken: Int = 0
let onSelect: () -> Void
+ @State private var interactionFrame: CGRect = .zero
@State private var isHovered = false
- @State private var wiggle = false
- @State private var globalFrame: CGRect = .zero
- @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = true
static let hoverScale: CGFloat = 1.22
static let labelHeight: CGFloat = 14
+ private static let hoverInAnimation = Animation.easeOut(duration: 0.07)
+ private static let hoverOutAnimation = Animation.easeOut(duration: 0.045)
static func stableWidth(iconSize: CGFloat) -> CGFloat {
iconSize * hoverScale + 8
@@ -41,6 +45,7 @@
icon: app.icon,
iconSize: iconSize,
payload: "\(app.path.path)\n\(sourceTag ?? "")",
+ resetToken: dragResetToken,
onLongPress: { onDragModeChange?(true) },
onDragEnd: { onDragModeChange?(false) },
onClick: onSelect
@@ -52,7 +57,6 @@
radius: isHovered ? 14 : 0,
y: isHovered ? 8 : 0
)
- .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
}
.frame(width: iconSlotSize, height: iconSlotSize)
@@ -61,44 +65,133 @@
.lineLimit(1)
.truncationMode(.tail)
.frame(width: labelWidth, height: labelHeight)
- .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
+ .opacity(showName ? 1 : (shouldShowHoverName ? 0.85 : 0))
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize))
.background(
- GeometryReader { proxy in
- Color.clear
- .onAppear { globalFrame = proxy.frame(in: .global) }
- .onChange(of: proxy.frame(in: .global)) { _, newFrame in
- globalFrame = newFrame
- }
+ AppHoverTrackingView { hovering, frame in
+ interactionFrame = frame
+ if bubbleDisabled || dragModeActive {
+ setHoverState(false)
+ onBubbleHover?(app, frame, .exited)
+ return
+ }
+ if hovering {
+ setHoverState(true)
+ onBubbleHover?(app, frame, .entered(canShowBubble: shouldShowAppBubble))
+ } else if isHovered {
+ setHoverState(false)
+ onBubbleHover?(app, frame, .exited)
+ } else {
+ setHoverState(false)
+ }
}
)
.contentShape(Rectangle())
- .onHover { hovering in
- isHovered = hovering
- if showUncommonAppBubbles && app.isUncommon {
- onBubbleHover?(app, globalFrame, hovering)
- }
- }
.contextMenu {
- if app.isUncommon {
- Button(tr("appNote.edit")) {
- onEditNote?(app, globalFrame)
- }
+ Button(tr("appNote.edit")) {
+ onEditNote?(app, interactionFrame)
}
}
- .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
- .animation(
- dragModeActive
- ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
- : .default,
- value: wiggle
- )
+ .opacity(dragModeActive ? 0.92 : 1)
+ .animation(.easeOut(duration: 0.08), value: dragModeActive)
.onChange(of: dragModeActive) { _, active in
- wiggle = active
+ if active {
+ setHoverState(false)
+ onBubbleHover?(app, interactionFrame, .exited)
+ }
}
+ .onChange(of: bubbleDisabled) { _, disabled in
+ if disabled {
+ setHoverState(false)
+ onBubbleHover?(app, interactionFrame, .exited)
+ }
+ }
+ .onDisappear {
+ if isHovered {
+ isHovered = false
+ onBubbleHover?(app, interactionFrame, .exited)
+ }
+ }
+ }
+
+ private var shouldShowAppBubble: Bool {
+ !showUncommonAppBubbles || app.isUncommon
+ }
+
+ private var shouldShowHoverName: Bool {
+ !showName && !shouldShowAppBubble && isHovered
+ }
+
+ private func setHoverState(_ hovering: Bool) {
+ guard isHovered != hovering else { return }
+ withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
+ isHovered = hovering
+ }
+ }
+}
+
+enum AppBubbleHoverEvent {
+ case entered(canShowBubble: Bool)
+ case exited
+}
+
+struct AppHoverTrackingView: NSViewRepresentable {
+ let onHover: (Bool, CGRect) -> Void
+
+ func makeNSView(context: Context) -> AppHoverTrackingNSView {
+ let view = AppHoverTrackingNSView()
+ view.onHover = onHover
+ return view
+ }
+
+ func updateNSView(_ view: AppHoverTrackingNSView, context: Context) {
+ view.onHover = onHover
+ }
+}
+
+final class AppHoverTrackingNSView: NSView {
+ var onHover: ((Bool, CGRect) -> Void)?
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ trackingAreas.forEach(removeTrackingArea)
+ addTrackingArea(
+ NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ )
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ nil
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ onHover?(true, rootLocalFrame())
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ onHover?(false, rootLocalFrame())
+ }
+
+ private func rootLocalFrame() -> CGRect {
+ guard let contentView = window?.contentView else { return .zero }
+ let rectInContent = contentView.convert(bounds, from: self)
+ let y = contentView.isFlipped
+ ? rectInContent.minY
+ : contentView.bounds.height - rectInContent.maxY
+ return CGRect(
+ x: rectInContent.minX,
+ y: y,
+ width: rectInContent.width,
+ height: rectInContent.height
+ )
}
}
@@ -112,6 +205,7 @@
let note: String?
let isEditing: Bool
let placement: BubblePlacement
+ let arrowOffset: CGFloat
@Binding var draftNote: String
var noteFocused: FocusState<Bool>.Binding
let onCommit: () -> Void
@@ -172,6 +266,12 @@
.shadow(color: .black.opacity(0.34), radius: 24, y: 16)
.shadow(color: .black.opacity(0.18), radius: 8, y: 3)
)
+ .overlay(alignment: placement == .above ? .bottom : .top) {
+ BubbleArrow(placement: placement)
+ .fill(Color.black.opacity(0.92))
+ .frame(width: 18, height: 9)
+ .offset(x: arrowOffset, y: placement == .above ? 8 : -8)
+ }
}
private var limitedDraft: Binding<String> {
@@ -182,10 +282,31 @@
}
}
+private struct BubbleArrow: Shape {
+ let placement: BubblePlacement
+
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ switch placement {
+ case .above:
+ path.move(to: CGPoint(x: rect.minX, y: rect.minY))
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
+ path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
+ case .below:
+ path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
+ path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
+ path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
+ }
+ path.closeSubpath()
+ return path
+ }
+}
+
private struct DraggableAppIconView: NSViewRepresentable {
let icon: NSImage
let iconSize: CGFloat
let payload: String
+ let resetToken: Int
let onLongPress: () -> Void
let onDragEnd: () -> Void
let onClick: () -> Void
@@ -195,6 +316,7 @@
view.image = icon
view.iconSize = iconSize
view.payload = payload
+ view.resetToken = resetToken
view.onLongPress = onLongPress
view.onDragEnd = onDragEnd
view.onClick = onClick
@@ -202,13 +324,21 @@
}
func updateNSView(_ view: DragIconNSView, context: Context) {
+ let imageChanged = view.image !== icon
+ let sizeChanged = view.iconSize != iconSize
view.image = icon
view.iconSize = iconSize
view.payload = payload
+ if view.resetToken != resetToken {
+ view.resetToken = resetToken
+ view.resetInteractionState()
+ }
view.onLongPress = onLongPress
view.onDragEnd = onDragEnd
view.onClick = onClick
- view.needsDisplay = true
+ if imageChanged || sizeChanged {
+ view.needsDisplay = true
+ }
}
}
@@ -216,6 +346,7 @@
var image: NSImage = NSImage()
var iconSize: CGFloat = 56
var payload: String = ""
+ var resetToken = 0
var onLongPress: (() -> Void)?
var onDragEnd: (() -> Void)?
var onClick: (() -> Void)?
@@ -225,6 +356,14 @@
private var isLongPressActive = false
private var longPressWorkItem: DispatchWorkItem?
override var isFlipped: Bool { true }
+
+ func resetInteractionState() {
+ longPressWorkItem?.cancel()
+ longPressWorkItem = nil
+ mouseDownEvent = nil
+ didStartDrag = false
+ isLongPressActive = false
+ }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
@@ -291,6 +430,7 @@
didStartDrag = false
isLongPressActive = false
mouseDownEvent = nil
+ longPressWorkItem = nil
}
private func makeDragImage() -> NSImage {
--
Gitblit v1.9.3