From 9abd2554308521c72bd0c3b60f6e7f5e7a129a8b Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 18 May 2026 16:42:50 +0800
Subject: [PATCH] Fix built-in app grouping and bubble hover settings
---
Apptag/AppGridItem.swift | 306 ++++++++++++++++++++++++++++++++++++++++++--------
1 files changed, 253 insertions(+), 53 deletions(-)
diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index f25f223..5cda31b 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -10,43 +10,86 @@
var sourceTag: String? = nil
var dragModeActive: Bool = false
var onDragModeChange: ((Bool) -> Void)? = nil
+ var onBubbleHover: ((AppInfo, CGRect, Bool) -> Void)? = nil
+ var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
+ var itemID: String
+ @Binding var hoveredAppItemID: String?
let onSelect: () -> Void
- @State private var isHovered = false
@State private var wiggle = false
+ @State private var interactionFrame: CGRect = .zero
+ @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
- private let hoverScale: CGFloat = 1.22
+ 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
+ }
+
+ static func stableHeight(iconSize: CGFloat) -> CGFloat {
+ iconSize * hoverScale + labelHeight + 22
+ }
+
+ private var iconSlotSize: CGFloat { iconSize * Self.hoverScale }
+ private var labelWidth: CGFloat { iconSize + 20 }
+ private var labelHeight: CGFloat { Self.labelHeight }
+ private var isHovered: Bool { hoveredAppItemID == itemID }
var body: some View {
VStack(spacing: 6) {
- DraggableAppIconView(
- icon: app.icon,
- iconSize: iconSize,
- payload: "\(app.path.path)\n\(sourceTag ?? "")",
- onHover: { isHovered = $0 },
- onLongPress: { onDragModeChange?(true) },
- onDragEnd: { onDragModeChange?(false) },
- onClick: onSelect
- )
- .frame(width: iconSize, height: iconSize)
- .scaleEffect(isHovered ? hoverScale : 1.0)
- .shadow(
- color: .black.opacity(isHovered ? 0.35 : 0),
- radius: isHovered ? 14 : 0,
- y: isHovered ? 8 : 0
- )
- .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
+ ZStack {
+ DraggableAppIconView(
+ icon: app.icon,
+ iconSize: iconSize,
+ payload: "\(app.path.path)\n\(sourceTag ?? "")",
+ onLongPress: { onDragModeChange?(true) },
+ onDragEnd: { onDragModeChange?(false) },
+ onClick: onSelect
+ )
+ .frame(width: iconSize, height: iconSize)
+ .scaleEffect(isHovered ? Self.hoverScale : 1.0)
+ .shadow(
+ color: .black.opacity(isHovered ? 0.35 : 0),
+ radius: isHovered ? 14 : 0,
+ y: isHovered ? 8 : 0
+ )
+ }
+ .frame(width: iconSlotSize, height: iconSlotSize)
Text(app.name)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.truncationMode(.tail)
- .frame(maxWidth: iconSize + 20)
- .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
+ .frame(width: labelWidth, height: labelHeight)
+ .opacity(showName ? 1 : (shouldShowHoverName ? 0.85 : 0))
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
- .contentShape(RoundedRectangle(cornerRadius: 10))
+ .frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize))
+ .background(
+ AppGridItemHoverTracker { hovering, frame in
+ interactionFrame = frame
+ if hovering {
+ setHoverState(true)
+ guard shouldShowAppBubble else { return }
+ onBubbleHover?(app, frame, hovering)
+ } else if isHovered {
+ setHoverState(false)
+ onBubbleHover?(app, frame, hovering)
+ } else {
+ setHoverState(false)
+ }
+ }
+ )
+ .contentShape(Rectangle())
+ .contextMenu {
+ Button(tr("appNote.edit")) {
+ onEditNote?(app, interactionFrame)
+ }
+ }
.rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
.animation(
dragModeActive
@@ -57,6 +100,194 @@
.onChange(of: dragModeActive) { _, active in
wiggle = active
}
+ .onDisappear {
+ if hoveredAppItemID == itemID {
+ hoveredAppItemID = nil
+ onBubbleHover?(app, interactionFrame, false)
+ }
+ }
+ }
+
+ private var shouldShowAppBubble: Bool {
+ !showUncommonAppBubbles || app.isUncommon
+ }
+
+ private var shouldShowHoverName: Bool {
+ !showName && !shouldShowAppBubble && isHovered
+ }
+
+ private func setHoverState(_ hovering: Bool) {
+ withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
+ if hovering {
+ hoveredAppItemID = itemID
+ } else if hoveredAppItemID == itemID {
+ hoveredAppItemID = nil
+ }
+ }
+ }
+}
+
+private struct AppGridItemHoverTracker: NSViewRepresentable {
+ let onHover: (Bool, CGRect) -> Void
+
+ func makeNSView(context: Context) -> HoverTrackingNSView {
+ let view = HoverTrackingNSView()
+ view.onHover = onHover
+ return view
+ }
+
+ func updateNSView(_ view: HoverTrackingNSView, context: Context) {
+ view.onHover = onHover
+ }
+}
+
+private final class HoverTrackingNSView: 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
+ )
+ }
+}
+
+enum BubblePlacement {
+ case above
+ case below
+}
+
+struct AppNameBubble: View {
+ let appName: String
+ let note: String?
+ let isEditing: Bool
+ let placement: BubblePlacement
+ let arrowOffset: CGFloat
+ @Binding var draftNote: String
+ var noteFocused: FocusState<Bool>.Binding
+ let onCommit: () -> Void
+ let onCancel: () -> Void
+
+ private var displayNote: String {
+ note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ }
+
+ var body: some View {
+ content
+ .onExitCommand(perform: onCancel)
+ }
+
+ private var content: some View {
+ VStack(spacing: isEditing || !displayNote.isEmpty ? 8 : 0) {
+ Text(appName)
+ .font(.system(size: isEditing ? 22 : 24, weight: .bold))
+ .foregroundStyle(.white)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .frame(maxWidth: .infinity)
+
+ if isEditing {
+ VStack(alignment: .trailing, spacing: 4) {
+ TextField(tr("appNote.placeholder"), text: limitedDraft)
+ .textFieldStyle(.plain)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(.white)
+ .padding(.horizontal, 12)
+ .frame(height: 36)
+ .background(
+ RoundedRectangle(cornerRadius: 9)
+ .fill(Color.white.opacity(0.12))
+ )
+ .focused(noteFocused)
+ .onSubmit(onCommit)
+ Text("\(draftNote.count) / \(TagDatabase.maxAppNoteLength)")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.white.opacity(0.35))
+ }
+ } else if !displayNote.isEmpty {
+ Text(displayNote)
+ .font(.system(size: 14, weight: .medium))
+ .lineSpacing(2)
+ .foregroundStyle(.white.opacity(0.78))
+ .lineLimit(4)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(.horizontal, isEditing ? 22 : 24)
+ .padding(.vertical, isEditing ? 18 : 16)
+ .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)
+ )
+ .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> {
+ Binding(
+ get: { draftNote },
+ set: { draftNote = String($0.prefix(TagDatabase.maxAppNoteLength)) }
+ )
+ }
+}
+
+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
}
}
@@ -64,7 +295,6 @@
let icon: NSImage
let iconSize: CGFloat
let payload: String
- let onHover: (Bool) -> Void
let onLongPress: () -> Void
let onDragEnd: () -> Void
let onClick: () -> Void
@@ -74,7 +304,6 @@
view.image = icon
view.iconSize = iconSize
view.payload = payload
- view.onHover = onHover
view.onLongPress = onLongPress
view.onDragEnd = onDragEnd
view.onClick = onClick
@@ -85,7 +314,6 @@
view.image = icon
view.iconSize = iconSize
view.payload = payload
- view.onHover = onHover
view.onLongPress = onLongPress
view.onDragEnd = onDragEnd
view.onClick = onClick
@@ -97,7 +325,6 @@
var image: NSImage = NSImage()
var iconSize: CGFloat = 56
var payload: String = ""
- var onHover: ((Bool) -> Void)?
var onLongPress: (() -> Void)?
var onDragEnd: (() -> Void)?
var onClick: (() -> Void)?
@@ -106,24 +333,7 @@
private var didStartDrag = false
private var isLongPressActive = false
private var longPressWorkItem: DispatchWorkItem?
- private var trackingAreaRef: NSTrackingArea?
-
override var isFlipped: Bool { true }
-
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let trackingAreaRef {
- removeTrackingArea(trackingAreaRef)
- }
- let area = NSTrackingArea(
- rect: bounds,
- options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- trackingAreaRef = area
- addTrackingArea(area)
- }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
@@ -134,10 +344,6 @@
height: iconSize
)
image.draw(in: rect)
- }
-
- override func mouseEntered(with event: NSEvent) {
- onHover?(true)
}
override func mouseDown(with event: NSEvent) {
@@ -194,12 +400,6 @@
didStartDrag = false
isLongPressActive = false
mouseDownEvent = nil
- }
-
- override func mouseExited(with event: NSEvent) {
- if !didStartDrag {
- onHover?(false)
- }
}
private func makeDragImage() -> NSImage {
--
Gitblit v1.9.3