From 9c317d89a39d0871f15f8282aec6c7f3dde7768c Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 26 Jun 2026 22:42:44 +0800
Subject: [PATCH] Polish Pro status UI and app grid layout
---
src/Apptag/AppGridCollectionView.swift | 1602 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 1,576 insertions(+), 26 deletions(-)
diff --git a/src/Apptag/AppGridCollectionView.swift b/src/Apptag/AppGridCollectionView.swift
index 54a218b..8e95bc8 100644
--- a/src/Apptag/AppGridCollectionView.swift
+++ b/src/Apptag/AppGridCollectionView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
+import Carbon.HIToolbox
fileprivate enum AppGridCollectionDisplayMode: Equatable {
case flat
@@ -27,6 +28,21 @@
static let externalInteraction = AppGridBubbleSuppressionReasons(rawValue: 1 << 0)
static let scroll = AppGridBubbleSuppressionReasons(rawValue: 1 << 1)
+ static let usageTipsHover = AppGridBubbleSuppressionReasons(rawValue: 1 << 2)
+}
+
+struct AppGridUsageTip: Equatable {
+ let id: Int
+ let titleKey: String
+ let detailKey: String
+}
+
+enum AppGridUsageTipsMetrics {
+ static let barHeight: CGFloat = 154
+ static let reservedHeight: CGFloat = 194
+ static let bottomMargin: CGFloat = 20
+ static let horizontalInset: CGFloat = 24
+ static let minWidth: CGFloat = 640
}
struct AppGridCollectionView: NSViewRepresentable {
@@ -35,9 +51,14 @@
let displayMode: String
let iconSize: CGFloat
let showNames: Bool
+ let appGridTheme: AppGridTheme
let bubbleDisabled: Bool
let showUncommonAppBubbles: Bool
let highlightedGroupName: String?
+ let bottomContentPadding: CGFloat
+ let usageTipsVisible: Bool
+ let usageTips: [AppGridUsageTip]
+ @Binding var selectedUsageTipIndex: Int
let contentRevision: Int
let scrollTargetID: String?
let scrollRequestToken: Int
@@ -46,9 +67,12 @@
let onEditNote: (AppInfo, CGRect) -> Void
let onDropApp: (String, String, String, Bool) -> Void
let onDropOutsideGroup: (String, String, Bool) -> Void
+ let onReorderApps: (String, [String]) -> Void
let onGroupActivate: (String) -> Void
let onScrollActivity: () -> Void
let onDragModeChange: (Bool) -> Void
+ let onHideUsageTips: () -> Void
+ let onUsageTipsHoverChange: (Bool) -> Void
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -61,15 +85,21 @@
}
func updateNSView(_ view: AppGridCollectionHostView, context: Context) {
+ let selectedUsageTipIndexBinding = $selectedUsageTipIndex
context.coordinator.update(
groups: groups,
tagColors: tagColors,
displayMode: displayMode,
iconSize: iconSize,
showNames: showNames,
+ appGridTheme: appGridTheme,
bubbleDisabled: bubbleDisabled,
showUncommonAppBubbles: showUncommonAppBubbles,
highlightedGroupName: highlightedGroupName,
+ bottomContentPadding: bottomContentPadding,
+ usageTipsVisible: usageTipsVisible,
+ usageTips: usageTips,
+ selectedUsageTipIndex: selectedUsageTipIndex,
contentRevision: contentRevision,
scrollTargetID: scrollTargetID,
scrollRequestToken: scrollRequestToken,
@@ -78,9 +108,13 @@
onEditNote: onEditNote,
onDropApp: onDropApp,
onDropOutsideGroup: onDropOutsideGroup,
+ onReorderApps: onReorderApps,
onGroupActivate: onGroupActivate,
onScrollActivity: onScrollActivity,
- onDragModeChange: onDragModeChange
+ onDragModeChange: onDragModeChange,
+ onHideUsageTips: onHideUsageTips,
+ onUsageTipIndexChange: { selectedUsageTipIndexBinding.wrappedValue = $0 },
+ onUsageTipsHoverChange: onUsageTipsHoverChange
)
view.applyCoordinatorUpdate()
}
@@ -91,10 +125,16 @@
var displayMode = AppDefaults.displayMode
var iconSize: CGFloat = AppDefaults.iconSize
var showNames = true
+ var appGridTheme = AppGridTheme.fallback
private var externalBubbleDisabled = false
private var scrollBubbleDisabled = false
var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
var highlightedGroupName: String?
+ var bottomContentPadding: CGFloat = 0
+ var usageTipsVisible = false
+ var usageTips: [AppGridUsageTip] = []
+ var selectedUsageTipIndex = 0
+ private var usageTipsBubbleDisabled = false
var contentRevision = 0
var scrollTargetID: String?
var scrollRequestToken = 0
@@ -107,9 +147,19 @@
var onEditNote: (AppInfo, CGRect) -> Void = { _, _ in }
var onDropApp: (String, String, String, Bool) -> Void = { _, _, _, _ in }
var onDropOutsideGroup: (String, String, Bool) -> Void = { _, _, _ in }
+ var onReorderApps: (String, [String]) -> Void = { _, _ in }
var onGroupActivate: (String) -> Void = { _ in }
var onScrollActivity: () -> Void = {}
var onDragModeChange: (Bool) -> Void = { _ in }
+ var onHideUsageTips: () -> Void = {}
+ var onUsageTipIndexChange: (Int) -> Void = { _ in }
+ var onUsageTipsHoverChange: (Bool) -> Void = { _ in }
+
+ private weak var activeReorderCard: AppGridGroupCardView?
+ private var activeDragPath = ""
+ private var activeDragSourceContainerID = ""
+ private var lastReorderContainerID = ""
+ private var lastReorderScreenFrame: NSRect?
func update(
groups: [TagGroup],
@@ -117,9 +167,14 @@
displayMode: String,
iconSize: CGFloat,
showNames: Bool,
+ appGridTheme: AppGridTheme,
bubbleDisabled: Bool,
showUncommonAppBubbles: Bool,
highlightedGroupName: String?,
+ bottomContentPadding: CGFloat,
+ usageTipsVisible: Bool,
+ usageTips: [AppGridUsageTip],
+ selectedUsageTipIndex: Int,
contentRevision: Int,
scrollTargetID: String?,
scrollRequestToken: Int,
@@ -128,18 +183,27 @@
onEditNote: @escaping (AppInfo, CGRect) -> Void,
onDropApp: @escaping (String, String, String, Bool) -> Void,
onDropOutsideGroup: @escaping (String, String, Bool) -> Void,
+ onReorderApps: @escaping (String, [String]) -> Void,
onGroupActivate: @escaping (String) -> Void,
onScrollActivity: @escaping () -> Void,
- onDragModeChange: @escaping (Bool) -> Void
+ onDragModeChange: @escaping (Bool) -> Void,
+ onHideUsageTips: @escaping () -> Void,
+ onUsageTipIndexChange: @escaping (Int) -> Void,
+ onUsageTipsHoverChange: @escaping (Bool) -> Void
) {
self.groups = groups
self.tagColors = tagColors
self.displayMode = displayMode
self.iconSize = iconSize
self.showNames = showNames
+ self.appGridTheme = appGridTheme
self.externalBubbleDisabled = bubbleDisabled
self.showUncommonAppBubbles = showUncommonAppBubbles
self.highlightedGroupName = highlightedGroupName
+ self.bottomContentPadding = max(0, bottomContentPadding)
+ self.usageTipsVisible = usageTipsVisible && !usageTips.isEmpty
+ self.usageTips = usageTips
+ self.selectedUsageTipIndex = Self.clampedUsageTipIndex(selectedUsageTipIndex, tips: usageTips)
self.contentRevision = contentRevision
self.scrollTargetID = scrollTargetID
self.scrollRequestToken = scrollRequestToken
@@ -148,16 +212,25 @@
self.onEditNote = onEditNote
self.onDropApp = onDropApp
self.onDropOutsideGroup = onDropOutsideGroup
+ self.onReorderApps = onReorderApps
self.onGroupActivate = onGroupActivate
self.onScrollActivity = onScrollActivity
self.onDragModeChange = onDragModeChange
+ self.onHideUsageTips = onHideUsageTips
+ self.onUsageTipIndexChange = onUsageTipIndexChange
+ self.onUsageTipsHoverChange = onUsageTipsHoverChange
+ if !self.usageTipsVisible {
+ usageTipsBubbleDisabled = false
+ }
let nextSignature = Self.signature(
tagColors: tagColors,
displayMode: displayMode,
iconSize: iconSize,
showNames: showNames,
+ appGridTheme: appGridTheme,
showUncommonAppBubbles: showUncommonAppBubbles,
+ bottomContentPadding: self.bottomContentPadding,
contentRevision: contentRevision
)
if nextSignature != contentSignature {
@@ -214,6 +287,9 @@
if scrollBubbleDisabled {
reasons.insert(.scroll)
}
+ if usageTipsBubbleDisabled {
+ reasons.insert(.usageTipsHover)
+ }
return reasons
}
@@ -228,12 +304,109 @@
return true
}
+ @discardableResult
+ func setUsageTipsBubbleDisabled(_ disabled: Bool) -> Bool {
+ guard usageTipsBubbleDisabled != disabled else { return false }
+ usageTipsBubbleDisabled = disabled
+ return true
+ }
+
+ func selectUsageTip(offset: Int) {
+ guard !usageTips.isEmpty else { return }
+ let count = usageTips.count
+ let nextIndex = (selectedUsageTipIndex + offset + count) % count
+ guard nextIndex != selectedUsageTipIndex else { return }
+ selectedUsageTipIndex = nextIndex
+ onUsageTipIndexChange(nextIndex)
+ }
+
+ fileprivate func beginAppIconDrag(path: String, sourceContainerID: String) {
+ activeDragPath = path
+ activeDragSourceContainerID = sourceContainerID
+ lastReorderContainerID = ""
+ lastReorderScreenFrame = nil
+ clearReorderInsertion()
+ onDragModeChange(true)
+ }
+
+ fileprivate func endAppIconDrag() {
+ clearReorderInsertion()
+ activeDragPath = ""
+ activeDragSourceContainerID = ""
+ lastReorderContainerID = ""
+ lastReorderScreenFrame = nil
+ onDragModeChange(false)
+ }
+
+ fileprivate func cancelAppIconDrag() {
+ clearReorderInsertion()
+ activeDragPath = ""
+ activeDragSourceContainerID = ""
+ lastReorderContainerID = ""
+ lastReorderScreenFrame = nil
+ onDragModeChange(false)
+ }
+
+ fileprivate func setReorderInsertion(card: AppGridGroupCardView, insertionIndex: Int) {
+ if let activeReorderCard, activeReorderCard !== card {
+ activeReorderCard.clearReorderInsertion()
+ }
+ activeReorderCard = card
+ lastReorderContainerID = card.containerID
+ lastReorderScreenFrame = card.screenFrame()
+ card.setReorderInsertion(index: insertionIndex)
+ }
+
+ fileprivate func clearReorderInsertion(card: AppGridGroupCardView? = nil) {
+ if let card {
+ card.clearReorderInsertion()
+ if activeReorderCard === card {
+ activeReorderCard = nil
+ }
+ if lastReorderContainerID == card.containerID {
+ lastReorderContainerID = ""
+ lastReorderScreenFrame = nil
+ }
+ return
+ }
+ activeReorderCard?.clearReorderInsertion()
+ activeReorderCard = nil
+ lastReorderContainerID = ""
+ lastReorderScreenFrame = nil
+ }
+
+ fileprivate func activeReorderPath(in containerID: String, copy: Bool) -> String? {
+ guard !copy,
+ !activeDragPath.isEmpty,
+ activeDragSourceContainerID == containerID
+ else { return nil }
+ return activeDragPath
+ }
+
+ fileprivate func shouldCancelEmptyDropForActiveReorder(
+ path: String,
+ screenPoint: NSPoint,
+ copy: Bool
+ ) -> Bool {
+ guard !copy,
+ !activeDragPath.isEmpty,
+ activeDragPath == path,
+ activeDragSourceContainerID == lastReorderContainerID,
+ let lastReorderScreenFrame
+ else { return false }
+
+ let guardOutset = AppGridCollectionMetrics.reorderEmptyDropCancelOutset
+ return lastReorderScreenFrame.insetBy(dx: -guardOutset, dy: -guardOutset).contains(screenPoint)
+ }
+
private static func signature(
tagColors: [String: Int],
displayMode: String,
iconSize: CGFloat,
showNames: Bool,
+ appGridTheme: AppGridTheme,
showUncommonAppBubbles: Bool,
+ bottomContentPadding: CGFloat,
contentRevision: Int
) -> String {
let colorPart = tagColors
@@ -244,10 +417,17 @@
displayMode,
"\(Int(iconSize.rounded()))",
showNames ? "names" : "nonames",
+ "theme=\(appGridTheme.rawValue)",
showUncommonAppBubbles ? "uncommon" : "allbubbles",
+ "bottom=\(Int(bottomContentPadding.rounded()))",
colorPart,
"rev=\(contentRevision)"
].joined(separator: "|")
+ }
+
+ private static func clampedUsageTipIndex(_ index: Int, tips: [AppGridUsageTip]) -> Int {
+ guard !tips.isEmpty else { return 0 }
+ return min(max(0, index), tips.count - 1)
}
}
}
@@ -267,12 +447,16 @@
private let scrollView = AppGridScrollView()
private let collectionView = NSCollectionView()
private let gridLayout = AppGridContainerCollectionLayout()
+ private let usageTipsShieldView = AppGridUsageTipsShieldView()
+ private let usageTipsView = AppGridUsageTipsNSView()
private weak var coordinator: AppGridCollectionView.Coordinator?
private var scrollObserver: NSObjectProtocol?
private var lastLayoutSize: NSSize = .zero
private var lastReportedBoundsOrigin: NSPoint?
private var scrollUnfreezeWorkItem: DispatchWorkItem?
private var scrollActivityIsActive = false
+ private var usageTipsEventRegionIsClaimed = false
+ private var usageTipsMouseMonitor: Any?
override var isFlipped: Bool { true }
@@ -290,19 +474,23 @@
if let scrollObserver {
NotificationCenter.default.removeObserver(scrollObserver)
}
+ removeUsageTipsMouseMonitor()
scrollUnfreezeWorkItem?.cancel()
+ coordinator?.cancelAppIconDrag()
AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
}
func configure(coordinator: AppGridCollectionView.Coordinator) {
self.coordinator = coordinator
gridLayout.coordinator = coordinator
+ usageTipsView.coordinator = coordinator
collectionView.dataSource = coordinator
collectionView.delegate = coordinator
}
func applyCoordinatorUpdate() {
guard let coordinator else { return }
+ applyAppearance()
if coordinator.needsReload {
coordinator.needsReload = false
collectionView.reloadData()
@@ -323,11 +511,14 @@
}
}
}
+ usageTipsView.applyCoordinatorState()
+ positionUsageTipsView()
}
override func layout() {
super.layout()
scrollView.frame = bounds
+ positionUsageTipsView()
if lastLayoutSize != bounds.size {
lastLayoutSize = bounds.size
gridLayout.invalidateLayout()
@@ -337,19 +528,51 @@
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
+ removeUsageTipsMouseMonitor()
+ AppDragCoordinator.shared.cancelDrag()
+ coordinator?.cancelAppIconDrag()
AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
} else {
+ installUsageTipsMouseMonitor()
AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self)
}
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ if !usageTipsView.isHidden,
+ usageTipsView.frame.contains(point) {
+ let tipsPoint = usageTipsView.convert(point, from: self)
+ return usageTipsView.hitTest(tipsPoint) ?? usageTipsView
+ }
+ return super.hitTest(point)
}
func performEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) {
guard let coordinator,
coordinator.displayStyle != .flat
else { return }
+ if coordinator.shouldCancelEmptyDropForActiveReorder(
+ path: path,
+ screenPoint: screenPoint,
+ copy: copy
+ ) {
+ coordinator.cancelAppIconDrag()
+ return
+ }
DispatchQueue.main.async {
coordinator.onDropOutsideGroup(path, source, copy)
}
+ }
+
+ func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool {
+ guard let coordinator,
+ coordinator.displayStyle != .flat
+ else { return false }
+ return !coordinator.shouldCancelEmptyDropForActiveReorder(
+ path: path,
+ screenPoint: screenPoint,
+ copy: copy
+ )
}
private func setup() {
@@ -371,6 +594,16 @@
scrollView.borderType = .noBorder
scrollView.documentView = collectionView
addSubview(scrollView)
+ usageTipsShieldView.isHidden = true
+ usageTipsShieldView.onClaim = { [weak self] in
+ self?.claimUsageTipsEventRegion()
+ }
+ usageTipsShieldView.onRelease = { [weak self] in
+ self?.releaseUsageTipsEventRegion()
+ }
+ usageTipsView.isHidden = true
+ addSubview(usageTipsShieldView, positioned: .above, relativeTo: scrollView)
+ addSubview(usageTipsView, positioned: .above, relativeTo: usageTipsShieldView)
scrollView.contentView.postsBoundsChangedNotifications = true
scrollObserver = NotificationCenter.default.addObserver(
@@ -385,7 +618,76 @@
}
}
+ private func positionUsageTipsView() {
+ guard let coordinator,
+ coordinator.usageTipsVisible
+ else {
+ usageTipsView.isHidden = true
+ usageTipsShieldView.isHidden = true
+ usageTipsView.clearHoverState()
+ releaseUsageTipsEventRegion()
+ usageTipsView.frame = .zero
+ usageTipsShieldView.frame = .zero
+ return
+ }
+
+ let inset = min(AppGridUsageTipsMetrics.horizontalInset, max(0, bounds.width / 4))
+ let availableWidth = max(1, bounds.width - inset * 2)
+ let width = usageTipsView.preferredWidth(maxAvailableWidth: availableWidth)
+ let height = AppGridUsageTipsMetrics.barHeight
+ usageTipsShieldView.isHidden = false
+ usageTipsShieldView.frame = usageTipsEventRegion()
+ usageTipsView.configureVisualLayout(
+ width: width,
+ height: height,
+ bottomMargin: AppGridUsageTipsMetrics.bottomMargin
+ )
+ usageTipsView.isHidden = false
+ usageTipsView.frame = usageTipsEventRegion()
+ }
+
+ fileprivate func relayoutUsageTipsAfterContentChange() {
+ positionUsageTipsView()
+ usageTipsView.needsLayout = true
+ }
+
+ private func usageTipsEventRegion() -> NSRect {
+ guard let coordinator,
+ coordinator.usageTipsVisible
+ else { return .zero }
+ let height = min(bounds.height, AppGridUsageTipsMetrics.reservedHeight)
+ return NSRect(
+ x: 0,
+ y: max(0, bounds.height - height),
+ width: bounds.width,
+ height: height
+ )
+ }
+
+ private func claimUsageTipsEventRegion() {
+ usageTipsEventRegionIsClaimed = true
+ window?.makeFirstResponder(usageTipsView)
+ if coordinator?.setUsageTipsBubbleDisabled(true) == true {
+ refreshVisibleRuntimeState()
+ }
+ coordinator?.onUsageTipsHoverChange(true)
+ }
+
+ private func releaseUsageTipsEventRegion() {
+ guard usageTipsEventRegionIsClaimed else { return }
+ usageTipsEventRegionIsClaimed = false
+ if coordinator?.setUsageTipsBubbleDisabled(false) == true {
+ refreshVisibleRuntimeState()
+ }
+ coordinator?.onUsageTipsHoverChange(false)
+ }
+
private func handleScrollActivity() {
+ if AppDragCoordinator.shared.hasActiveDrag {
+ AppDragCoordinator.shared.cancelDrag()
+ }
+ coordinator?.cancelAppIconDrag()
+
if !scrollActivityIsActive {
scrollActivityIsActive = true
if coordinator?.setScrollBubbleDisabled(true) == true {
@@ -413,6 +715,62 @@
}
}
+ fileprivate func refreshVisibleRuntimeStateForUsageTips() {
+ refreshVisibleRuntimeState()
+ }
+
+ func handleUsageTipsMouseDown(_ event: NSEvent) -> Bool {
+ handleUsageTipsMouseEvent(event, triggerButtons: true)
+ }
+
+ private func handleUsageTipsMouseEvent(_ event: NSEvent, triggerButtons: Bool) -> Bool {
+ let point = usageTipsHostPoint(for: event)
+ guard !usageTipsView.isHidden,
+ usageTipsView.frame.contains(point)
+ else { return false }
+
+ return usageTipsView.handleMouseEventFromHost(event, triggerButtons: triggerButtons)
+ }
+
+ private func installUsageTipsMouseMonitor() {
+ guard usageTipsMouseMonitor == nil else { return }
+ usageTipsMouseMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [
+ .leftMouseDown,
+ .leftMouseUp,
+ .rightMouseDown,
+ .rightMouseUp,
+ .otherMouseDown,
+ .otherMouseUp
+ ]
+ ) { [weak self] event in
+ guard let self,
+ self.window != nil,
+ self.handleUsageTipsMouseEvent(
+ event,
+ triggerButtons: event.type == .leftMouseDown
+ )
+ else { return event }
+ return nil
+ }
+ }
+
+ private func usageTipsHostPoint(for event: NSEvent) -> NSPoint {
+ if event.window === window {
+ return convert(event.locationInWindow, from: nil)
+ }
+ guard let window else { return .zero }
+ let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
+ return convert(windowPoint, from: nil)
+ }
+
+ private func removeUsageTipsMouseMonitor() {
+ if let usageTipsMouseMonitor {
+ NSEvent.removeMonitor(usageTipsMouseMonitor)
+ self.usageTipsMouseMonitor = nil
+ }
+ }
+
private func replayPointerHover() {
guard let window else { return }
let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
@@ -433,6 +791,934 @@
}
return didScroll
}
+
+ private func applyAppearance() {
+ let usesDarkGlass = coordinator?.appGridTheme.usesDarkGlass == true
+ layer?.backgroundColor = NSColor.clear.cgColor
+ collectionView.appearance = usesDarkGlass ? NSAppearance(named: .darkAqua) : nil
+ }
+}
+
+private final class AppGridUsageTipsShieldView: NSView {
+ var onClaim: (() -> Void)?
+ var onRelease: (() -> Void)?
+
+ private var trackingAreaRef: NSTrackingArea?
+
+ override var isFlipped: Bool { true }
+ override var acceptsFirstResponder: Bool { false }
+ override var mouseDownCanMoveWindow: Bool { false }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let trackingAreaRef {
+ removeTrackingArea(trackingAreaRef)
+ }
+ let area = NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(area)
+ trackingAreaRef = area
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard !isHidden, bounds.contains(point) else { return nil }
+ onClaim?()
+ return self
+ }
+
+ override func mouseEntered(with event: NSEvent) { onClaim?() }
+ override func mouseMoved(with event: NSEvent) { onClaim?() }
+ override func mouseDragged(with event: NSEvent) { onClaim?() }
+ override func mouseExited(with event: NSEvent) { onRelease?() }
+ override func mouseDown(with event: NSEvent) {
+ Diagnostics.log("usageTips.shield.mouseDown")
+ onClaim?()
+ }
+ override func mouseUp(with event: NSEvent) {}
+ override func rightMouseDown(with event: NSEvent) { onClaim?() }
+ override func rightMouseUp(with event: NSEvent) {}
+ override func otherMouseDown(with event: NSEvent) { onClaim?() }
+ override func otherMouseUp(with event: NSEvent) {}
+ override func scrollWheel(with event: NSEvent) { onClaim?() }
+}
+
+private final class AppGridUsageTipsNSView: NSView {
+ weak var coordinator: AppGridCollectionView.Coordinator?
+
+ private let backgroundView = NSVisualEffectView()
+ private let titlePanelView = NSView()
+ private let iconView = AppGridDecorativeImageView()
+ private let titleLabel = NSTextField(labelWithString: "")
+ private let detailScrollView = NSScrollView()
+ private let detailLabel = NSTextField(labelWithString: "")
+ private let closeButton = AppGridUsageTipIconButton(systemImage: "xmark")
+ private let previousButton = AppGridUsageTipIconButton(systemImage: "chevron.left")
+ private let nextButton = AppGridUsageTipIconButton(systemImage: "chevron.right")
+ private let dotsView = AppGridUsageTipDotsView()
+ private var trackingAreaRef: NSTrackingArea?
+ private var isPointerInside = false
+ private var visualWidth: CGFloat = AppGridUsageTipsMetrics.minWidth
+ private var visualHeight: CGFloat = AppGridUsageTipsMetrics.barHeight
+ private var visualBottomMargin: CGFloat = AppGridUsageTipsMetrics.bottomMargin
+ private var currentDetailText = ""
+
+ private let titleFont = NSFont.systemFont(ofSize: 25, weight: .bold)
+ private let detailFont = NSFont.systemFont(ofSize: 24, weight: .medium)
+
+ override var isFlipped: Bool { true }
+ override var acceptsFirstResponder: Bool { true }
+ override var mouseDownCanMoveWindow: Bool { false }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ setup()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setup()
+ }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ if window == nil {
+ clearHoverState()
+ }
+ }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let trackingAreaRef {
+ removeTrackingArea(trackingAreaRef)
+ }
+ let area = NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(area)
+ trackingAreaRef = area
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard !isHidden, bounds.contains(point) else { return nil }
+ setPointerInside(true)
+ let hitView = super.hitTest(point)
+ if hitView == nil || hitView === backgroundView || hitView?.isDescendant(of: backgroundView) == true {
+ return self
+ }
+ return hitView
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ claimInteractionFocus()
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ claimInteractionFocus()
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ setPointerInside(false)
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ Diagnostics.log("usageTips.hud.mouseDown")
+ if routeButtonClickIfNeeded(event) {
+ return
+ }
+ claimInteractionFocus()
+ }
+
+ override func mouseDragged(with event: NSEvent) {
+ claimInteractionFocus()
+ }
+
+ override func mouseUp(with event: NSEvent) {}
+ override func rightMouseDown(with event: NSEvent) { claimInteractionFocus() }
+ override func rightMouseUp(with event: NSEvent) {}
+ override func otherMouseDown(with event: NSEvent) { claimInteractionFocus() }
+ override func otherMouseUp(with event: NSEvent) {}
+
+ override func keyDown(with event: NSEvent) {
+ switch Int(event.keyCode) {
+ case kVK_LeftArrow:
+ selectUsageTip(offset: -1)
+ case kVK_RightArrow:
+ selectUsageTip(offset: 1)
+ default:
+ super.keyDown(with: event)
+ }
+ }
+
+ override func scrollWheel(with event: NSEvent) {
+ detailScrollView.scrollWheel(with: event)
+ }
+
+ override func layout() {
+ super.layout()
+ let visualFrame = currentVisualFrame()
+ backgroundView.frame = visualFrame
+
+ let outerPaddingX: CGFloat = 20
+ let outerPaddingY: CGFloat = 19
+ let titlePanelWidth = min(
+ max(350, visualFrame.width * 0.24),
+ min(460, visualFrame.width * 0.36)
+ )
+ let titlePanelHeight = max(1, min(116, visualFrame.height - outerPaddingY * 2))
+ let titlePanelFrame = NSRect(
+ x: visualFrame.minX + outerPaddingX,
+ y: visualFrame.midY - titlePanelHeight / 2,
+ width: titlePanelWidth,
+ height: titlePanelHeight
+ )
+ titlePanelView.frame = titlePanelFrame
+
+ let iconSize: CGFloat = 26
+ let controlZoneWidth: CGFloat = min(148, max(128, visualFrame.width * 0.08))
+ let buttonGap: CGFloat = 12
+ let buttonSize: CGFloat = 52
+ let buttonDotsGap: CGFloat = 10
+ let dotsHeight: CGFloat = 10
+ let dotsWidth = dotsView.preferredWidth
+
+ let titleIconGap: CGFloat = 20
+ let titleSidePadding: CGFloat = 28
+ let titleX = titlePanelFrame.minX + titleSidePadding + iconSize + titleIconGap
+ let titleWidth = max(1, titlePanelFrame.maxX - titleX - titleSidePadding)
+ let titleHeight: CGFloat = 68
+ let titleY = titlePanelFrame.midY - titleHeight / 2
+ let detailGap = visualFrame.width < 980 ? CGFloat(30) : CGFloat(52)
+ let controlGap = visualFrame.width < 980 ? CGFloat(24) : CGFloat(36)
+ let detailX = titlePanelFrame.maxX + detailGap
+ let controlsMaxX = visualFrame.maxX - 54
+ let textRight = controlsMaxX - controlZoneWidth - controlGap
+ let detailHeight: CGFloat = 88
+ let detailY = visualFrame.minY + max(0, (visualFrame.height - detailHeight) / 2)
+
+ let buttonsWidth = buttonSize * 2 + buttonGap
+ let buttonsGroupMinX = controlsMaxX - buttonsWidth
+
+ iconView.frame = NSRect(
+ x: titlePanelFrame.minX + titleSidePadding,
+ y: titlePanelFrame.midY - iconSize / 2,
+ width: iconSize,
+ height: iconSize
+ )
+
+ titleLabel.frame = NSRect(
+ x: titleX,
+ y: titleY,
+ width: titleWidth,
+ height: titleHeight
+ )
+
+ closeButton.frame = NSRect(
+ x: visualFrame.maxX - 40,
+ y: visualFrame.minY + 10,
+ width: 30,
+ height: 30
+ )
+
+ let controlGroupHeight = buttonSize + buttonDotsGap + dotsHeight
+ let buttonY = visualFrame.minY + max(0, (visualFrame.height - controlGroupHeight) / 2)
+ nextButton.frame = NSRect(
+ x: buttonsGroupMinX + buttonSize + buttonGap,
+ y: buttonY,
+ width: buttonSize,
+ height: buttonSize
+ )
+ previousButton.frame = NSRect(
+ x: buttonsGroupMinX,
+ y: buttonY,
+ width: buttonSize,
+ height: buttonSize
+ )
+ dotsView.frame = NSRect(
+ x: buttonsGroupMinX + (buttonsWidth - dotsWidth) / 2,
+ y: previousButton.frame.maxY + buttonDotsGap,
+ width: dotsWidth,
+ height: dotsHeight
+ )
+
+ detailScrollView.frame = NSRect(
+ x: detailX,
+ y: detailY,
+ width: max(1, textRight - detailX),
+ height: detailHeight
+ )
+ layoutDetailLabel()
+ }
+
+ func applyCoordinatorState() {
+ guard let coordinator,
+ coordinator.usageTipsVisible,
+ !coordinator.usageTips.isEmpty
+ else {
+ isHidden = true
+ clearHoverState()
+ return
+ }
+
+ isHidden = false
+ let safeIndex = min(max(0, coordinator.selectedUsageTipIndex), coordinator.usageTips.count - 1)
+ let tip = coordinator.usageTips[safeIndex]
+ titleLabel.stringValue = tr(tip.titleKey)
+ updateTipIcon(id: tip.id)
+ currentDetailText = formattedTipDetail(tr(tip.detailKey))
+ closeButton.buttonAccessibilityLabel = tr("usageTips.close")
+ previousButton.buttonAccessibilityLabel = tr("usageTips.previous")
+ nextButton.buttonAccessibilityLabel = tr("usageTips.next")
+ dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex)
+ updateColors()
+ needsLayout = true
+ enclosingHostView?.relayoutUsageTipsAfterContentChange()
+ }
+
+ func clearHoverState() {
+ guard isPointerInside else { return }
+ setPointerInside(false)
+ }
+
+ func configureVisualLayout(width: CGFloat, height: CGFloat, bottomMargin: CGFloat) {
+ visualWidth = max(1, width)
+ visualHeight = max(1, height)
+ visualBottomMargin = max(0, bottomMargin)
+ needsLayout = true
+ }
+
+ func preferredWidth(maxAvailableWidth: CGFloat) -> CGFloat {
+ max(1, maxAvailableWidth)
+ }
+
+ private func setup() {
+ wantsLayer = true
+ layer?.shadowColor = NSColor.black.cgColor
+ layer?.shadowOpacity = 0.14
+ layer?.shadowRadius = 12
+ layer?.shadowOffset = NSSize(width: 0, height: -2)
+
+ backgroundView.material = .popover
+ backgroundView.blendingMode = .withinWindow
+ backgroundView.state = .active
+ backgroundView.wantsLayer = true
+ backgroundView.layer?.cornerRadius = 10
+ backgroundView.layer?.masksToBounds = true
+ backgroundView.layer?.borderWidth = 1
+ addSubview(backgroundView)
+
+ titlePanelView.wantsLayer = true
+ titlePanelView.layer?.cornerRadius = 18
+ titlePanelView.layer?.masksToBounds = true
+ titlePanelView.layer?.borderWidth = 1
+ addSubview(titlePanelView)
+
+ let iconConfig = NSImage.SymbolConfiguration(pointSize: 26, weight: .bold)
+ iconView.image = NSImage(
+ systemSymbolName: "lightbulb.fill",
+ accessibilityDescription: nil
+ )?.withSymbolConfiguration(iconConfig)
+ iconView.imageScaling = .scaleProportionallyDown
+ addSubview(iconView)
+
+ configureLabel(titleLabel, font: titleFont, lineBreakMode: .byWordWrapping)
+ configureDetailLabel()
+
+ detailScrollView.drawsBackground = false
+ detailScrollView.hasVerticalScroller = false
+ detailScrollView.hasHorizontalScroller = true
+ detailScrollView.autohidesScrollers = true
+ detailScrollView.borderType = .noBorder
+ detailScrollView.documentView = detailLabel
+
+ closeButton.action = { [weak self] in self?.requestHideUsageTips() }
+ previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) }
+ nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) }
+
+ addSubview(titleLabel)
+ addSubview(detailScrollView)
+ addSubview(dotsView)
+ addSubview(closeButton)
+ addSubview(previousButton)
+ addSubview(nextButton)
+
+ setAccessibilityRole(.group)
+ updateColors()
+ }
+
+ private func configureLabel(_ label: NSTextField, font: NSFont, lineBreakMode: NSLineBreakMode) {
+ label.cell = AppGridCenteredTextFieldCell(textCell: "")
+ label.isEditable = false
+ label.isSelectable = false
+ label.drawsBackground = false
+ label.isBordered = false
+ label.lineBreakMode = lineBreakMode
+ label.maximumNumberOfLines = 2
+ label.alignment = .left
+ label.font = font
+ }
+
+ private func configureDetailLabel() {
+ detailLabel.cell = AppGridCenteredMultilineTextFieldCell(textCell: "")
+ detailLabel.isEditable = false
+ detailLabel.isSelectable = false
+ detailLabel.drawsBackground = false
+ detailLabel.isBordered = false
+ detailLabel.lineBreakMode = .byWordWrapping
+ detailLabel.maximumNumberOfLines = 2
+ detailLabel.font = detailFont
+ detailLabel.cell?.usesSingleLineMode = false
+ detailLabel.cell?.wraps = true
+ }
+
+ private func claimInteractionFocus() {
+ setPointerInside(true)
+ window?.makeFirstResponder(self)
+ }
+
+ func handleMouseEventFromHost(_ event: NSEvent, triggerButtons: Bool) -> Bool {
+ let point = usageTipsPoint(for: event)
+ guard bounds.contains(point) else { return false }
+ if triggerButtons, routeButtonClickIfNeeded(event) {
+ return true
+ }
+ claimInteractionFocus()
+ return true
+ }
+
+ private func selectUsageTip(offset: Int) {
+ coordinator?.selectUsageTip(offset: offset)
+ applyCoordinatorState()
+ }
+
+ private func requestHideUsageTips() {
+ Diagnostics.log("usageTips.hud.close")
+ clearHoverState()
+ coordinator?.onHideUsageTips()
+ }
+
+ private func routeButtonClickIfNeeded(_ event: NSEvent) -> Bool {
+ let point = usageTipsPoint(for: event)
+ let closeHitOutset: CGFloat = 8
+ if closeButton.frame.insetBy(dx: -closeHitOutset, dy: -closeHitOutset).contains(point) {
+ closeButton.performPressFeedback()
+ requestHideUsageTips()
+ return true
+ }
+ let hitOutset: CGFloat = 6
+ if nextButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) {
+ Diagnostics.log("usageTips.hud.routedNext")
+ selectUsageTip(offset: 1)
+ return true
+ }
+ if previousButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) {
+ Diagnostics.log("usageTips.hud.routedPrevious")
+ selectUsageTip(offset: -1)
+ return true
+ }
+ return false
+ }
+
+ private func usageTipsPoint(for event: NSEvent) -> NSPoint {
+ if event.window === window {
+ return convert(event.locationInWindow, from: nil)
+ }
+ guard let window else { return .zero }
+ let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
+ return convert(windowPoint, from: nil)
+ }
+
+ private func setPointerInside(_ inside: Bool) {
+ guard isPointerInside != inside else { return }
+ isPointerInside = inside
+ if coordinator?.setUsageTipsBubbleDisabled(inside) == true {
+ enclosingHostView?.refreshVisibleRuntimeStateForUsageTips()
+ }
+ coordinator?.onUsageTipsHoverChange(inside)
+ }
+
+ private var enclosingHostView: AppGridCollectionHostView? {
+ var current = superview
+ while let view = current {
+ if let host = view as? AppGridCollectionHostView {
+ return host
+ }
+ current = view.superview
+ }
+ return nil
+ }
+
+ private func layoutDetailLabel() {
+ let width = max(1, detailScrollView.contentView.bounds.width)
+ detailLabel.preferredMaxLayoutWidth = width
+ detailLabel.frame = NSRect(x: 0, y: 0, width: width, height: detailScrollView.bounds.height)
+ }
+
+ private func currentVisualFrame() -> NSRect {
+ let width = min(bounds.width, visualWidth)
+ let height = min(bounds.height, visualHeight)
+ return NSRect(
+ x: max(0, (bounds.width - width) / 2),
+ y: max(0, bounds.height - visualBottomMargin - height),
+ width: width,
+ height: height
+ )
+ }
+
+ private func formattedTipDetail(_ text: String) -> String {
+ let normalized = text
+ .replacingOccurrences(of: "\\s*(?:-->|->|>|→|>|,)\\s*", with: "\n", options: .regularExpression)
+ .replacingOccurrences(of: "[ \\t]{2,}", with: " ", options: .regularExpression)
+ .replacingOccurrences(of: "\\n{2,}", with: "\n", options: .regularExpression)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return orderedTipDetail(normalized)
+ }
+
+ private func orderedTipDetail(_ text: String) -> String {
+ let lines = text
+ .components(separatedBy: .newlines)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ guard lines.count > 1 else { return text }
+ return lines
+ .enumerated()
+ .map { "\($0.offset + 1). \($0.element)" }
+ .joined(separator: "\n")
+ }
+
+ private func updateTipIcon(id: Int) {
+ let symbolName: String
+ switch id {
+ case 1:
+ symbolName = "tag.fill"
+ case 2:
+ symbolName = "checkmark.seal.fill"
+ case 3:
+ symbolName = "arrow.up.and.down.and.arrow.left.and.right"
+ case 4:
+ symbolName = "doc.on.doc.fill"
+ case 5:
+ symbolName = "minus.circle.fill"
+ case 6:
+ symbolName = "arrow.up.arrow.down.circle.fill"
+ case 7:
+ symbolName = "square.grid.2x2.fill"
+ case 8:
+ symbolName = "note.text"
+ default:
+ symbolName = "lightbulb.fill"
+ }
+ let iconConfig = NSImage.SymbolConfiguration(pointSize: 26, weight: .bold)
+ iconView.image = NSImage(
+ systemSymbolName: symbolName,
+ accessibilityDescription: nil
+ )?.withSymbolConfiguration(iconConfig)
+ ?? NSImage(
+ systemSymbolName: "lightbulb.fill",
+ accessibilityDescription: nil
+ )?.withSymbolConfiguration(iconConfig)
+ }
+
+ private func updateColors() {
+ let usesDarkGlass = coordinator?.appGridTheme.usesDarkGlass == true
+
+ backgroundView.material = usesDarkGlass ? .underWindowBackground : .popover
+ backgroundView.appearance = usesDarkGlass ? NSAppearance(named: .darkAqua) : nil
+ backgroundView.layer?.backgroundColor = (
+ usesDarkGlass
+ ? NSColor.black.withAlphaComponent(0.46)
+ : NSColor.white.withAlphaComponent(0.54)
+ ).cgColor
+ backgroundView.layer?.borderColor = (
+ usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.22)
+ : NSColor.black.withAlphaComponent(0.18)
+ ).cgColor
+
+ titlePanelView.layer?.backgroundColor = (
+ usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.08)
+ : NSColor.white.withAlphaComponent(0.42)
+ ).cgColor
+ titlePanelView.layer?.borderColor = (
+ usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.12)
+ : NSColor.black.withAlphaComponent(0.08)
+ ).cgColor
+
+ let theme = coordinator?.appGridTheme ?? .fallback
+ let accentColor = usageTipAccentColor(for: theme)
+ iconView.contentTintColor = accentColor
+ titleLabel.textColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.92)
+ : accentColor.withAlphaComponent(0.94)
+ detailLabel.textColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.95)
+ : NSColor(calibratedRed: 0.08, green: 0.10, blue: 0.13, alpha: 0.94)
+ applyDetailTextStyle()
+
+ let buttonTint = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.90)
+ : NSColor(calibratedWhite: 0.08, alpha: 0.92)
+ let buttonHoverTint = usesDarkGlass
+ ? NSColor.white
+ : NSColor(calibratedWhite: 0.04, alpha: 1.00)
+ let buttonPressedTint = usesDarkGlass
+ ? NSColor.white
+ : NSColor.black
+ let buttonSurface = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.10)
+ : NSColor.white.withAlphaComponent(0.38)
+ let buttonHoverSurface = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.20)
+ : NSColor.white.withAlphaComponent(0.70)
+ let buttonPressedSurface = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.28)
+ : NSColor.white.withAlphaComponent(0.86)
+ let buttonBorder = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.14)
+ : NSColor.black.withAlphaComponent(0.10)
+ [previousButton, nextButton].forEach { button in
+ button.tintColor = buttonTint
+ button.hoveredTintColor = buttonHoverTint
+ button.pressedTintColor = buttonPressedTint
+ button.surfaceColor = buttonSurface
+ button.hoveredSurfaceColor = buttonHoverSurface
+ button.pressedSurfaceColor = buttonPressedSurface
+ button.borderColor = buttonBorder
+ }
+
+ closeButton.tintColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.42)
+ : NSColor.black.withAlphaComponent(0.34)
+ closeButton.hoveredTintColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.86)
+ : NSColor.black.withAlphaComponent(0.78)
+ closeButton.pressedTintColor = usesDarkGlass ? .white : .black
+ closeButton.surfaceColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.04)
+ : NSColor.white.withAlphaComponent(0.08)
+ closeButton.hoveredSurfaceColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.14)
+ : NSColor.white.withAlphaComponent(0.62)
+ closeButton.pressedSurfaceColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.22)
+ : NSColor.white.withAlphaComponent(0.82)
+ closeButton.borderColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.08)
+ : NSColor.black.withAlphaComponent(0.08)
+
+ dotsView.selectedColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.88)
+ : NSColor.labelColor.withAlphaComponent(0.74)
+ dotsView.idleColor = usesDarkGlass
+ ? NSColor.white.withAlphaComponent(0.28)
+ : NSColor.labelColor.withAlphaComponent(0.24)
+
+ layer?.shadowOpacity = usesDarkGlass ? 0.28 : 0.14
+ layer?.shadowRadius = usesDarkGlass ? 18 : 12
+ needsDisplay = true
+ }
+
+ private func usageTipAccentColor(for theme: AppGridTheme) -> NSColor {
+ switch theme {
+ case .defaultLight:
+ return NSColor(calibratedRed: 0.34, green: 0.40, blue: 0.48, alpha: 1.00)
+ case .deepBlue:
+ return NSColor(calibratedRed: 0.42, green: 0.80, blue: 1.00, alpha: 1.00)
+ case .black:
+ return NSColor(calibratedRed: 0.36, green: 0.74, blue: 1.00, alpha: 1.00)
+ case .pink:
+ return NSColor(calibratedRed: 0.70, green: 0.18, blue: 0.45, alpha: 1.00)
+ case .purple:
+ return NSColor(calibratedRed: 0.40, green: 0.22, blue: 0.70, alpha: 1.00)
+ case .green:
+ return NSColor(calibratedRed: 0.08, green: 0.42, blue: 0.22, alpha: 1.00)
+ case .blue:
+ return NSColor(calibratedRed: 0.06, green: 0.34, blue: 0.74, alpha: 1.00)
+ case .colorful:
+ return NSColor(calibratedRed: 0.06, green: 0.44, blue: 0.78, alpha: 1.00)
+ }
+ }
+
+ private func applyDetailTextStyle() {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.alignment = .left
+ paragraphStyle.lineBreakMode = .byWordWrapping
+ paragraphStyle.minimumLineHeight = 31
+ paragraphStyle.maximumLineHeight = 34
+ paragraphStyle.lineSpacing = 6
+
+ detailLabel.attributedStringValue = NSAttributedString(
+ string: currentDetailText,
+ attributes: [
+ .font: detailFont,
+ .foregroundColor: detailLabel.textColor ?? NSColor.labelColor,
+ .paragraphStyle: paragraphStyle
+ ]
+ )
+ }
+}
+
+private final class AppGridCenteredTextFieldCell: NSTextFieldCell {
+ override func drawingRect(forBounds rect: NSRect) -> NSRect {
+ var drawingRect = super.drawingRect(forBounds: rect)
+ let textHeight = cellSize(forBounds: rect).height
+ drawingRect.origin.y = rect.origin.y + max(0, (rect.height - textHeight) / 2)
+ drawingRect.size.height = min(rect.height, textHeight + 2)
+ return drawingRect
+ }
+}
+
+private final class AppGridCenteredMultilineTextFieldCell: NSTextFieldCell {
+ override func drawingRect(forBounds rect: NSRect) -> NSRect {
+ var drawingRect = super.drawingRect(forBounds: rect)
+ let textHeight = attributedStringValue.boundingRect(
+ with: NSSize(width: max(1, rect.width), height: .greatestFiniteMagnitude),
+ options: [.usesLineFragmentOrigin, .usesFontLeading]
+ ).height
+ let centeredHeight = min(rect.height, ceil(textHeight) + 4)
+ drawingRect.origin.y = rect.origin.y + max(0, (rect.height - centeredHeight) / 2)
+ drawingRect.size.height = centeredHeight
+ return drawingRect
+ }
+}
+
+private final class AppGridDecorativeImageView: NSImageView {
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ nil
+ }
+
+ override func isAccessibilityElement() -> Bool {
+ false
+ }
+}
+
+private final class AppGridUsageTipIconButton: NSView {
+ var action: (() -> Void)?
+ var buttonAccessibilityLabel: String = "" {
+ didSet {
+ setAccessibilityLabel(buttonAccessibilityLabel)
+ }
+ }
+ var tintColor: NSColor = .labelColor {
+ didSet {
+ updateIconTint()
+ }
+ }
+ var hoveredTintColor: NSColor? {
+ didSet { updateIconTint() }
+ }
+ var pressedTintColor: NSColor? {
+ didSet { updateIconTint() }
+ }
+ var surfaceColor: NSColor = .clear {
+ didSet { needsDisplay = true }
+ }
+ var hoveredSurfaceColor: NSColor = NSColor.labelColor.withAlphaComponent(0.08) {
+ didSet { needsDisplay = true }
+ }
+ var pressedSurfaceColor: NSColor = NSColor.labelColor.withAlphaComponent(0.14) {
+ didSet { needsDisplay = true }
+ }
+ var borderColor: NSColor = .clear {
+ didSet { needsDisplay = true }
+ }
+
+ private let imageView = AppGridDecorativeImageView()
+ private var trackingAreaRef: NSTrackingArea?
+ private var isHovered = false
+ private var isPressed = false
+
+ override var isFlipped: Bool { true }
+ override var acceptsFirstResponder: Bool { false }
+ override var mouseDownCanMoveWindow: Bool { false }
+
+ init(systemImage: String) {
+ super.init(frame: .zero)
+ setup(systemImage: systemImage)
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+ true
+ }
+
+ override func isAccessibilityElement() -> Bool {
+ true
+ }
+
+ override func accessibilityRole() -> NSAccessibility.Role? {
+ .button
+ }
+
+ override func accessibilityLabel() -> String? {
+ buttonAccessibilityLabel
+ }
+
+ override func accessibilityPerformPress() -> Bool {
+ action?()
+ return true
+ }
+
+ func performPressFeedback() {
+ isPressed = true
+ needsDisplay = true
+ updateIconTint()
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in
+ self?.isPressed = false
+ self?.needsDisplay = true
+ self?.updateIconTint()
+ }
+ }
+
+ override func layout() {
+ super.layout()
+ let size = min(CGFloat(26), max(14, min(bounds.width, bounds.height) * 0.62))
+ imageView.frame = NSRect(
+ x: (bounds.width - size) / 2,
+ y: (bounds.height - size) / 2,
+ width: size,
+ height: size
+ )
+ }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let trackingAreaRef {
+ removeTrackingArea(trackingAreaRef)
+ }
+ let area = NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(area)
+ trackingAreaRef = area
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ bounds.contains(point) ? self : nil
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ isHovered = true
+ needsDisplay = true
+ updateIconTint()
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ isHovered = false
+ isPressed = false
+ needsDisplay = true
+ updateIconTint()
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ Diagnostics.log("usageTips.button.mouseDown", [
+ "label": buttonAccessibilityLabel
+ ])
+ performPressFeedback()
+ action?()
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 8, yRadius: 8)
+ let fill = isPressed ? pressedSurfaceColor : isHovered ? hoveredSurfaceColor : surfaceColor
+ fill.setFill()
+ path.fill()
+ borderColor.setStroke()
+ path.lineWidth = 1
+ path.stroke()
+ }
+
+ private func setup(systemImage: String) {
+ wantsLayer = true
+ let config = NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold)
+ imageView.image = NSImage(
+ systemSymbolName: systemImage,
+ accessibilityDescription: nil
+ )?.withSymbolConfiguration(config)
+ imageView.imageScaling = .scaleProportionallyDown
+ imageView.contentTintColor = tintColor
+ addSubview(imageView)
+ setAccessibilityRole(.button)
+ }
+
+ private func updateIconTint() {
+ if isPressed, let pressedTintColor {
+ imageView.contentTintColor = pressedTintColor
+ } else if isHovered, let hoveredTintColor {
+ imageView.contentTintColor = hoveredTintColor
+ } else {
+ imageView.contentTintColor = tintColor
+ }
+ }
+}
+
+private final class AppGridUsageTipDotsView: NSView {
+ private var count = 0
+ private var selectedIndex = 0
+ var selectedColor: NSColor = .labelColor {
+ didSet { needsDisplay = true }
+ }
+ var idleColor: NSColor = NSColor.labelColor.withAlphaComponent(0.28) {
+ didSet { needsDisplay = true }
+ }
+
+ override var isFlipped: Bool { true }
+
+ var preferredWidth: CGFloat {
+ guard count > 0 else { return 0 }
+ return CGFloat(count) * 5 + CGFloat(max(0, count - 1)) * 5 + 2
+ }
+
+ func configure(count: Int, selectedIndex: Int) {
+ self.count = count
+ self.selectedIndex = selectedIndex
+ needsDisplay = true
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard count > 0 else { return }
+ let spacing: CGFloat = 5
+ let totalWidth = preferredWidth
+ var x = (bounds.width - totalWidth) / 2
+ let centerY = bounds.midY
+
+ for index in 0..<count {
+ let selected = index == selectedIndex
+ let dotSize: CGFloat = selected ? 6 : 5
+ let color = selected ? selectedColor : idleColor
+ color.setFill()
+ NSBezierPath(
+ ovalIn: NSRect(
+ x: x,
+ y: centerY - dotSize / 2,
+ width: dotSize,
+ height: dotSize
+ )
+ ).fill()
+ x += 5 + spacing
+ }
+ }
+
}
private final class AppGridContainerCollectionLayout: NSCollectionViewLayout {
@@ -461,7 +1747,8 @@
groups: visibleGroups,
contentWidth: collectionView.bounds.width,
iconSize: iconSize,
- displayMode: coordinator?.displayMode ?? AppDefaults.displayMode
+ displayMode: coordinator?.displayMode ?? AppDefaults.displayMode,
+ bottomContentPadding: coordinator?.bottomContentPadding ?? 0
)
var nextAttributes: [IndexPath: NSCollectionViewLayoutAttributes] = [:]
@@ -507,22 +1794,39 @@
groups: [TagGroup],
contentWidth: CGFloat,
iconSize: CGFloat,
- displayMode: String
+ displayMode: String,
+ bottomContentPadding: CGFloat
) -> LayoutPlan {
switch AppGridCollectionDisplayMode(displayMode) {
case .flat:
- return makeFlatPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+ return makeFlatPlan(
+ groups: groups,
+ contentWidth: contentWidth,
+ iconSize: iconSize,
+ bottomContentPadding: bottomContentPadding
+ )
case .masonryContainer:
- return makeMasonryPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+ return makeMasonryPlan(
+ groups: groups,
+ contentWidth: contentWidth,
+ iconSize: iconSize,
+ bottomContentPadding: bottomContentPadding
+ )
case .gridContainer:
- return makeGridPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+ return makeGridPlan(
+ groups: groups,
+ contentWidth: contentWidth,
+ iconSize: iconSize,
+ bottomContentPadding: bottomContentPadding
+ )
}
}
private static func makeGridPlan(
groups: [TagGroup],
contentWidth: CGFloat,
- iconSize: CGFloat
+ iconSize: CGFloat,
+ bottomContentPadding: CGFloat
) -> LayoutPlan {
let boundedContentWidth = max(1, contentWidth)
let outerPadding = AppGridCollectionMetrics.outerPadding
@@ -552,7 +1856,8 @@
y += height + gap
}
- let contentHeight = rows.isEmpty ? outerPadding * 2 : y - gap + outerPadding
+ let contentHeight = (rows.isEmpty ? outerPadding * 2 : y - gap + outerPadding)
+ + max(0, bottomContentPadding)
return LayoutPlan(
items: items,
contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
@@ -562,7 +1867,8 @@
private static func makeFlatPlan(
groups: [TagGroup],
contentWidth: CGFloat,
- iconSize: CGFloat
+ iconSize: CGFloat,
+ bottomContentPadding: CGFloat
) -> LayoutPlan {
let boundedContentWidth = max(1, contentWidth)
let outerPadding = AppGridCollectionMetrics.outerPadding
@@ -585,7 +1891,8 @@
y += height + AppGridCollectionMetrics.flatGroupGap
}
- let contentHeight = groups.isEmpty ? outerPadding * 2 : y - AppGridCollectionMetrics.flatGroupGap + outerPadding
+ let contentHeight = (groups.isEmpty ? outerPadding * 2 : y - AppGridCollectionMetrics.flatGroupGap + outerPadding)
+ + max(0, bottomContentPadding)
return LayoutPlan(
items: items,
contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
@@ -595,7 +1902,8 @@
private static func makeMasonryPlan(
groups: [TagGroup],
contentWidth: CGFloat,
- iconSize: CGFloat
+ iconSize: CGFloat,
+ bottomContentPadding: CGFloat
) -> LayoutPlan {
let boundedContentWidth = max(1, contentWidth)
let outerPadding = AppGridCollectionMetrics.outerPadding
@@ -605,8 +1913,12 @@
let columnWidth = floor((availableWidth - gap * CGFloat(columnCount - 1)) / CGFloat(columnCount))
var columnHeights = Array(repeating: outerPadding, count: columnCount)
var items: [LayoutItem] = []
+ let bottomPinnedGroups = groups.enumerated().filter { $0.element.containerID == AppContainerID.appleBuiltIn }
+ let bottomPinnedIndexes = Set(bottomPinnedGroups.map { $0.offset })
for (index, group) in groups.enumerated() {
+ if bottomPinnedIndexes.contains(index) { continue }
+
let columnIndex = columnHeights.indices.min { columnHeights[$0] < columnHeights[$1] } ?? 0
let x = outerPadding + CGFloat(columnIndex) * (columnWidth + gap)
let y = columnHeights[columnIndex]
@@ -624,8 +1936,25 @@
columnHeights[columnIndex] = y + height + gap
}
- let tallest = columnHeights.max() ?? outerPadding
- let contentHeight = groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding
+ var contentBottom = columnHeights.max() ?? outerPadding
+ for (index, group) in bottomPinnedGroups {
+ let y = max(outerPadding, contentBottom)
+ let height = AppGridCollectionMetrics.cardHeight(
+ appCount: group.apps.count,
+ width: availableWidth,
+ iconSize: iconSize
+ )
+ items.append(
+ LayoutItem(
+ index: index,
+ frame: NSRect(x: outerPadding, y: y, width: availableWidth, height: height)
+ )
+ )
+ contentBottom = y + height + gap
+ }
+
+ let contentHeight = (items.isEmpty ? outerPadding * 2 : contentBottom - gap + outerPadding)
+ + max(0, bottomContentPadding)
return LayoutPlan(
items: items,
contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
@@ -761,6 +2090,7 @@
static let headerBottomGap: CGFloat = 6
static let iconColumnGap: CGFloat = 6
static let iconRowGap: CGFloat = 2
+ static let reorderEmptyDropCancelOutset: CGFloat = 4
static let hoverScale: CGFloat = 1.22
static let labelHeight: CGFloat = 14
@@ -844,9 +2174,11 @@
private weak var coordinator: AppGridCollectionView.Coordinator?
private var group: TagGroup?
private var iconViews: [AppGridIconNSView] = []
+ private var reorderInsertionIndex: Int?
private var isMouseInside = false
private var isHovered = false
private var trackingAreaRef: NSTrackingArea?
+ var containerID: String { group?.containerID ?? "" }
override var isFlipped: Bool { true }
@@ -878,6 +2210,7 @@
AppDragCoordinator.shared.unregister(id: dropTargetID)
group = nil
coordinator = nil
+ reorderInsertionIndex = nil
isMouseInside = false
isHovered = false
layer?.shadowOpacity = 0
@@ -970,31 +2303,92 @@
if displayStyle.usesCardSurface {
let rect = bounds.insetBy(dx: 0.5, dy: 0.5)
let path = NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14)
- cardSurfaceColor().setFill()
- path.fill()
- if coordinator.isColoredContainerMode || isColorlessActive {
- tagColor.withAlphaComponent(0.30).setFill()
+ if coordinator.appGridTheme.usesDarkGlass {
+ drawDarkCardSurface(
+ path: path,
+ tagColor: tagColor,
+ isActive: isHovered || isNavigationHighlighted,
+ isTinted: coordinator.isColoredContainerMode || isColorlessActive
+ )
+ } else {
+ cardSurfaceColor().setFill()
path.fill()
+ if coordinator.isColoredContainerMode || isColorlessActive {
+ tagColor.withAlphaComponent(0.30).setFill()
+ path.fill()
+ }
+ NSColor.labelColor.withAlphaComponent(0.08).setStroke()
+ path.lineWidth = 1
+ path.stroke()
}
- NSColor.labelColor.withAlphaComponent(0.08).setStroke()
- path.lineWidth = 1
- path.stroke()
}
drawHeader(title: group.name, displayStyle: displayStyle)
+ drawReorderInsertionIfNeeded()
}
func performDrop(path: String, source: String, copy: Bool) {
+ performDrop(path: path, source: source, sourceContainerID: "", copy: copy)
+ }
+
+ func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) {
guard let group else { return }
+ if sourceContainerID == group.containerID {
+ if !copy,
+ let orderedPaths = reorderedAppPaths(moving: path) {
+ coordinator?.onReorderApps(group.containerID, orderedPaths)
+ }
+ coordinator?.endAppIconDrag()
+ clearReorderInsertion()
+ return
+ }
+
coordinator?.onDropApp(path, source, group.name, copy)
if source != group.name || copy {
coordinator?.onDragModeChange(false)
}
}
+ func appDragHoverChanged(active: Bool) {
+ if !active {
+ coordinator?.clearReorderInsertion(card: self)
+ }
+ }
+
+ func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) {
+ guard let coordinator,
+ let group,
+ coordinator.activeReorderPath(in: group.containerID, copy: copy) != nil,
+ let window
+ else {
+ coordinator?.clearReorderInsertion(card: self)
+ return
+ }
+
+ let windowPoint = window.convertPoint(fromScreen: screenPoint)
+ let localPoint = convert(windowPoint, from: nil)
+ coordinator.setReorderInsertion(
+ card: self,
+ insertionIndex: insertionIndex(for: localPoint)
+ )
+ }
+
func replayPointerHover(windowPoint: NSPoint) {
setPointerInside(bounds.contains(convert(windowPoint, from: nil)))
iconViews.forEach { $0.replayPointerHover(windowPoint: windowPoint) }
+ }
+
+ func setReorderInsertion(index: Int) {
+ let clamped = min(max(0, index), iconViews.count)
+ guard reorderInsertionIndex != clamped else { return }
+ reorderInsertionIndex = clamped
+ needsDisplay = true
+ }
+
+ func clearReorderInsertion() {
+ guard reorderInsertionIndex != nil else { return }
+ reorderInsertionIndex = nil
+ needsDisplay = true
}
private func rebuildIconViews() {
@@ -1011,6 +2405,7 @@
iconView.configure(
app: app,
sourceTag: group.name,
+ sourceContainerID: group.containerID,
iconSize: coordinator.iconSize,
showName: coordinator.showNames,
coordinator: coordinator
@@ -1018,6 +2413,100 @@
addSubview(iconView)
return iconView
}
+ }
+
+ private func insertionIndex(for point: NSPoint) -> Int {
+ guard !iconViews.isEmpty else { return 0 }
+
+ var bestIndex = iconViews.count
+ var bestDistance = CGFloat.greatestFiniteMagnitude
+
+ for index in 0...iconViews.count {
+ guard let candidate = insertionCandidatePoint(for: index) else { continue }
+ let dx = point.x - candidate.x
+ let dy = point.y - candidate.y
+ let distance = dx * dx + dy * dy
+ if distance < bestDistance {
+ bestDistance = distance
+ bestIndex = index
+ }
+ }
+
+ return bestIndex
+ }
+
+ private func reorderedAppPaths(moving path: String) -> [String]? {
+ guard let group,
+ let insertionIndex = reorderInsertionIndex
+ else { return nil }
+
+ var paths = group.apps.map { $0.path.path }
+ guard let sourceIndex = paths.firstIndex(of: path) else { return nil }
+
+ paths.remove(at: sourceIndex)
+ var targetIndex = insertionIndex
+ if sourceIndex < insertionIndex {
+ targetIndex -= 1
+ }
+ targetIndex = min(max(0, targetIndex), paths.count)
+ paths.insert(path, at: targetIndex)
+
+ let originalPaths = group.apps.map { $0.path.path }
+ guard paths != originalPaths else { return nil }
+ return paths
+ }
+
+ private func drawReorderInsertionIfNeeded() {
+ guard let insertionIndex = reorderInsertionIndex,
+ let rect = insertionLineRect(for: insertionIndex)
+ else { return }
+
+ NSColor.controlAccentColor.withAlphaComponent(0.92).setFill()
+ let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
+ path.fill()
+ }
+
+ private func insertionLineRect(for index: Int) -> NSRect? {
+ guard let candidate = insertionCandidatePoint(for: index) else { return nil }
+ let referenceFrame: NSRect
+ if iconViews.indices.contains(index) {
+ referenceFrame = iconViews[index].frame
+ } else if let lastFrame = iconViews.last?.frame {
+ referenceFrame = lastFrame
+ } else {
+ return nil
+ }
+
+ let iconSize = coordinator?.iconSize ?? CGFloat(AppDefaults.iconSize)
+ let lineHeight = max(28, min(referenceFrame.height - 8, iconSize + 18))
+ return NSRect(
+ x: candidate.x - 2,
+ y: referenceFrame.midY - lineHeight / 2,
+ width: 4,
+ height: lineHeight
+ )
+ }
+
+ private func insertionCandidatePoint(for index: Int) -> NSPoint? {
+ guard !iconViews.isEmpty else { return nil }
+
+ if index <= 0 {
+ let first = iconViews[0].frame
+ return NSPoint(x: first.minX - 3, y: first.midY)
+ }
+
+ if index >= iconViews.count {
+ let last = iconViews[iconViews.count - 1].frame
+ return NSPoint(x: last.maxX + 3, y: last.midY)
+ }
+
+ let previous = iconViews[index - 1].frame
+ let next = iconViews[index].frame
+ if abs(previous.midY - next.midY) < 4 {
+ return NSPoint(x: (previous.maxX + next.minX) / 2, y: previous.midY)
+ }
+
+ return NSPoint(x: next.minX - 3, y: next.midY)
}
private func registerDropTargetIfNeeded() {
@@ -1051,6 +2540,17 @@
&& (isHovered || isNavigationHighlighted)
let shouldShadow = coordinator.displayStyle.usesCardSurface
&& ((coordinator.isColoredContainerMode && (isHovered || isNavigationHighlighted)) || isColorlessActive)
+ if coordinator.appGridTheme.usesDarkGlass && coordinator.displayStyle.usesCardSurface {
+ let tagColor = TagColor.nsColor(for: coordinator.tagColors[group?.name ?? ""] ?? 0)
+ let active = isHovered || isNavigationHighlighted
+ layer?.shadowColor = active
+ ? tagColor.withAlphaComponent(0.82).cgColor
+ : NSColor.black.withAlphaComponent(0.72).cgColor
+ layer?.shadowOpacity = active ? 0.50 : 0.28
+ layer?.shadowRadius = active ? 24 : 14
+ layer?.shadowOffset = NSSize(width: 0, height: active ? -8 : -4)
+ return
+ }
layer?.shadowColor = NSColor.black.cgColor
layer?.shadowOpacity = shouldShadow ? 0.22 : 0
layer?.shadowRadius = shouldShadow ? 8 : 0
@@ -1086,6 +2586,40 @@
return NSColor.white.withAlphaComponent(0.62)
}
+ private func drawDarkCardSurface(
+ path: NSBezierPath,
+ tagColor: NSColor,
+ isActive: Bool,
+ isTinted: Bool
+ ) {
+ NSGraphicsContext.saveGraphicsState()
+ if isActive {
+ let glow = NSShadow()
+ glow.shadowColor = tagColor.withAlphaComponent(0.36)
+ glow.shadowBlurRadius = 28
+ glow.shadowOffset = NSSize(width: 0, height: -8)
+ glow.set()
+ }
+ NSColor(calibratedRed: 0.12, green: 0.18, blue: 0.25, alpha: 0.58).setFill()
+ path.fill()
+ NSGraphicsContext.restoreGraphicsState()
+
+ if isTinted {
+ tagColor.withAlphaComponent(isActive ? 0.18 : 0.10).setFill()
+ path.fill()
+ }
+
+ NSColor.white.withAlphaComponent(isActive ? 0.08 : 0.045).setFill()
+ path.fill()
+
+ let strokeColor = isActive
+ ? tagColor.withAlphaComponent(0.72)
+ : NSColor.white.withAlphaComponent(0.18)
+ strokeColor.setStroke()
+ path.lineWidth = isActive ? 1.4 : 1.0
+ path.stroke()
+ }
+
private func drawHeader(title: String, displayStyle: AppGridCollectionDisplayMode) {
let horizontalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
let verticalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
@@ -1095,9 +2629,13 @@
width: max(1, bounds.width - horizontalInset * 2),
height: AppGridCollectionMetrics.headerHeight
)
+ let isDarkGrid = coordinator?.appGridTheme.usesDarkGlass == true
+ let headerTextColor = isDarkGrid
+ ? NSColor.white.withAlphaComponent(0.74)
+ : NSColor.secondaryLabelColor
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 18, weight: .semibold),
- .foregroundColor: NSColor.secondaryLabelColor,
+ .foregroundColor: headerTextColor,
.paragraphStyle: centeredParagraph(lineBreak: .byTruncatingMiddle)
]
let titleSize = title.size(withAttributes: attributes)
@@ -1109,7 +2647,9 @@
height: headerRect.height - 6
)
let lineY = headerRect.midY
- NSColor.secondaryLabelColor.withAlphaComponent(0.25).setStroke()
+ (isDarkGrid ? NSColor.white : NSColor.secondaryLabelColor)
+ .withAlphaComponent(isDarkGrid ? 0.16 : 0.25)
+ .setStroke()
let leftLine = NSBezierPath()
leftLine.move(to: NSPoint(x: headerRect.minX, y: lineY))
leftLine.line(to: NSPoint(x: max(headerRect.minX, titleRect.minX - 2), y: lineY))
@@ -1142,6 +2682,7 @@
private weak var coordinator: AppGridCollectionView.Coordinator?
private var app: AppInfo?
private var sourceTag = ""
+ private var sourceContainerID = ""
private var iconSize: CGFloat = AppDefaults.iconSize
private var showName = true
private var isMouseInside = false
@@ -1166,12 +2707,14 @@
func configure(
app: AppInfo,
sourceTag: String,
+ sourceContainerID: String,
iconSize: CGFloat,
showName: Bool,
coordinator: AppGridCollectionView.Coordinator
) {
self.app = app
self.sourceTag = sourceTag
+ self.sourceContainerID = sourceContainerID
self.iconSize = iconSize
self.showName = showName
self.coordinator = coordinator
@@ -1191,6 +2734,7 @@
mouseDownEvent = nil
didStartDrag = false
isLongPressActive = false
+ sourceContainerID = ""
longPressWorkItem = nil
}
@@ -1293,6 +2837,12 @@
didStartDrag = true
longPressWorkItem?.cancel()
+ if let app {
+ coordinator?.beginAppIconDrag(
+ path: app.path.path,
+ sourceContainerID: sourceContainerID
+ )
+ }
AppDragCoordinator.shared.beginDrag(
image: makeDragImage(),
payload: dragPayload,
@@ -1309,11 +2859,11 @@
at: screenPoint(for: event),
copy: event.modifierFlags.contains(.option)
)
- coordinator?.onDragModeChange(false)
+ coordinator?.endAppIconDrag()
} else if !isLongPressActive, let app {
coordinator?.onSelectApp(app)
} else {
- coordinator?.onDragModeChange(false)
+ coordinator?.cancelAppIconDrag()
}
didStartDrag = false
isLongPressActive = false
@@ -1355,7 +2905,7 @@
private var dragPayload: String {
guard let app else { return "" }
- return "\(app.path.path)\n\(sourceTag)"
+ return "\(app.path.path)\n\(sourceTag)\n\(sourceContainerID)"
}
private func setHover(_ hover: Bool, notify: Bool) {
--
Gitblit v1.9.3