From 1030214551f03d50a98e7a218648d89803c4c057 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 15:51:51 +0800
Subject: [PATCH] Native app grid usage tips
---
src/Apptag/AppGridCollectionView.swift | 428 ++++++++++++++++++++++++++++++++
src/Scripts/usage_tips_qa.sh | 100 ++++++-
src/Apptag/ContentView.swift | 206 +++------------
3 files changed, 556 insertions(+), 178 deletions(-)
diff --git a/src/Apptag/AppGridCollectionView.swift b/src/Apptag/AppGridCollectionView.swift
index cfcd15d..788dc43 100644
--- a/src/Apptag/AppGridCollectionView.swift
+++ b/src/Apptag/AppGridCollectionView.swift
@@ -27,6 +27,20 @@
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 = 68
+ static let reservedHeight: CGFloat = 104
+ static let bottomMargin: CGFloat = 18
+ static let horizontalInset: CGFloat = AppGridCollectionMetrics.outerPadding
}
struct AppGridCollectionView: NSViewRepresentable {
@@ -39,6 +53,9 @@
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
@@ -51,6 +68,7 @@
let onGroupActivate: (String) -> Void
let onScrollActivity: () -> Void
let onDragModeChange: (Bool) -> Void
+ let onUsageTipsHoverChange: (Bool) -> Void
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -63,6 +81,7 @@
}
func updateNSView(_ view: AppGridCollectionHostView, context: Context) {
+ let selectedUsageTipIndexBinding = $selectedUsageTipIndex
context.coordinator.update(
groups: groups,
tagColors: tagColors,
@@ -73,6 +92,9 @@
showUncommonAppBubbles: showUncommonAppBubbles,
highlightedGroupName: highlightedGroupName,
bottomContentPadding: bottomContentPadding,
+ usageTipsVisible: usageTipsVisible,
+ usageTips: usageTips,
+ selectedUsageTipIndex: selectedUsageTipIndex,
contentRevision: contentRevision,
scrollTargetID: scrollTargetID,
scrollRequestToken: scrollRequestToken,
@@ -84,7 +106,9 @@
onReorderApps: onReorderApps,
onGroupActivate: onGroupActivate,
onScrollActivity: onScrollActivity,
- onDragModeChange: onDragModeChange
+ onDragModeChange: onDragModeChange,
+ onUsageTipIndexChange: { selectedUsageTipIndexBinding.wrappedValue = $0 },
+ onUsageTipsHoverChange: onUsageTipsHoverChange
)
view.applyCoordinatorUpdate()
}
@@ -100,6 +124,10 @@
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
@@ -116,6 +144,8 @@
var onGroupActivate: (String) -> Void = { _ in }
var onScrollActivity: () -> Void = {}
var onDragModeChange: (Bool) -> Void = { _ in }
+ var onUsageTipIndexChange: (Int) -> Void = { _ in }
+ var onUsageTipsHoverChange: (Bool) -> Void = { _ in }
private weak var activeReorderCard: AppGridGroupCardView?
private var activeDragPath = ""
@@ -133,6 +163,9 @@
showUncommonAppBubbles: Bool,
highlightedGroupName: String?,
bottomContentPadding: CGFloat,
+ usageTipsVisible: Bool,
+ usageTips: [AppGridUsageTip],
+ selectedUsageTipIndex: Int,
contentRevision: Int,
scrollTargetID: String?,
scrollRequestToken: Int,
@@ -144,7 +177,9 @@
onReorderApps: @escaping (String, [String]) -> Void,
onGroupActivate: @escaping (String) -> Void,
onScrollActivity: @escaping () -> Void,
- onDragModeChange: @escaping (Bool) -> Void
+ onDragModeChange: @escaping (Bool) -> Void,
+ onUsageTipIndexChange: @escaping (Int) -> Void,
+ onUsageTipsHoverChange: @escaping (Bool) -> Void
) {
self.groups = groups
self.tagColors = tagColors
@@ -155,6 +190,9 @@
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
@@ -167,6 +205,11 @@
self.onGroupActivate = onGroupActivate
self.onScrollActivity = onScrollActivity
self.onDragModeChange = onDragModeChange
+ self.onUsageTipIndexChange = onUsageTipIndexChange
+ self.onUsageTipsHoverChange = onUsageTipsHoverChange
+ if !self.usageTipsVisible {
+ usageTipsBubbleDisabled = false
+ }
let nextSignature = Self.signature(
tagColors: tagColors,
@@ -231,6 +274,9 @@
if scrollBubbleDisabled {
reasons.insert(.scroll)
}
+ if usageTipsBubbleDisabled {
+ reasons.insert(.usageTipsHover)
+ }
return reasons
}
@@ -243,6 +289,22 @@
guard scrollBubbleDisabled != disabled else { return false }
scrollBubbleDisabled = disabled
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) {
@@ -347,6 +409,11 @@
"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)
+ }
}
}
@@ -365,6 +432,7 @@
private let scrollView = AppGridScrollView()
private let collectionView = NSCollectionView()
private let gridLayout = AppGridContainerCollectionLayout()
+ private let usageTipsView = AppGridUsageTipsNSView()
private weak var coordinator: AppGridCollectionView.Coordinator?
private var scrollObserver: NSObjectProtocol?
private var lastLayoutSize: NSSize = .zero
@@ -396,6 +464,7 @@
func configure(coordinator: AppGridCollectionView.Coordinator) {
self.coordinator = coordinator
gridLayout.coordinator = coordinator
+ usageTipsView.coordinator = coordinator
collectionView.dataSource = coordinator
collectionView.delegate = coordinator
}
@@ -422,11 +491,14 @@
}
}
}
+ usageTipsView.applyCoordinatorState()
+ positionUsageTipsView()
}
override func layout() {
super.layout()
scrollView.frame = bounds
+ positionUsageTipsView()
if lastLayoutSize != bounds.size {
lastLayoutSize = bounds.size
gridLayout.invalidateLayout()
@@ -480,6 +552,8 @@
scrollView.borderType = .noBorder
scrollView.documentView = collectionView
addSubview(scrollView)
+ usageTipsView.isHidden = true
+ addSubview(usageTipsView)
scrollView.contentView.postsBoundsChangedNotifications = true
scrollObserver = NotificationCenter.default.addObserver(
@@ -492,6 +566,24 @@
else { return }
self.handleScrollActivity()
}
+ }
+
+ private func positionUsageTipsView() {
+ guard let coordinator,
+ coordinator.usageTipsVisible
+ else {
+ usageTipsView.isHidden = true
+ usageTipsView.clearHoverState()
+ usageTipsView.frame = .zero
+ return
+ }
+
+ let inset = min(AppGridUsageTipsMetrics.horizontalInset, max(0, bounds.width / 4))
+ let width = max(1, bounds.width - inset * 2)
+ let height = AppGridUsageTipsMetrics.barHeight
+ let y = max(0, bounds.height - AppGridUsageTipsMetrics.bottomMargin - height)
+ usageTipsView.isHidden = false
+ usageTipsView.frame = NSRect(x: inset, y: y, width: width, height: height)
}
private func handleScrollActivity() {
@@ -527,6 +619,10 @@
}
}
+ fileprivate func refreshVisibleRuntimeStateForUsageTips() {
+ refreshVisibleRuntimeState()
+ }
+
private func replayPointerHover() {
guard let window else { return }
let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
@@ -549,6 +645,334 @@
}
}
+private final class AppGridUsageTipsNSView: NSView {
+ weak var coordinator: AppGridCollectionView.Coordinator?
+
+ private let titleBackgroundView = NSView()
+ private let titleLabel = NSTextField(labelWithString: "")
+ private let detailScrollView = NSScrollView()
+ private let detailLabel = NSTextField(labelWithString: "")
+ private let controlsView = NSView()
+ 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
+
+ override var isFlipped: Bool { true }
+ override var acceptsFirstResponder: Bool { false }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ setup()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setup()
+ }
+
+ 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, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ )
+ addTrackingArea(area)
+ trackingAreaRef = area
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard !isHidden, bounds.contains(point) else { return nil }
+ return super.hitTest(point) ?? self
+ }
+
+ override func mouseEntered(with event: NSEvent) {
+ setPointerInside(true)
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ setPointerInside(false)
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ window?.makeKeyAndOrderFront(nil)
+ }
+
+ override func mouseUp(with event: NSEvent) {}
+ override func rightMouseDown(with event: NSEvent) {}
+ override func rightMouseUp(with event: NSEvent) {}
+
+ override func scrollWheel(with event: NSEvent) {
+ detailScrollView.scrollWheel(with: event)
+ }
+
+ override func layout() {
+ super.layout()
+ let titleWidth = min(320, max(240, bounds.width * 0.24))
+ let controlsWidth: CGFloat = 120
+ let detailWidth = max(1, bounds.width - titleWidth - controlsWidth)
+
+ titleBackgroundView.frame = NSRect(x: 0, y: 0, width: titleWidth, height: bounds.height)
+ titleLabel.frame = titleBackgroundView.bounds.insetBy(dx: 18, dy: 0)
+ detailScrollView.frame = NSRect(x: titleWidth, y: 0, width: detailWidth, height: bounds.height)
+ controlsView.frame = NSRect(x: titleWidth + detailWidth, y: 0, width: controlsWidth, height: bounds.height)
+
+ let buttonSize: CGFloat = 34
+ let buttonY: CGFloat = 9
+ previousButton.frame = NSRect(x: 22, y: buttonY, width: buttonSize, height: buttonSize)
+ nextButton.frame = NSRect(x: 64, y: buttonY, width: buttonSize, height: buttonSize)
+ dotsView.frame = NSRect(x: 18, y: 48, width: controlsWidth - 36, height: 10)
+
+ 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)
+ detailLabel.stringValue = tr(tip.detailKey)
+ previousButton.accessibilityLabel = tr("usageTips.previous")
+ nextButton.accessibilityLabel = tr("usageTips.next")
+ dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex)
+ updateColors()
+ layoutDetailLabel()
+ }
+
+ func clearHoverState() {
+ guard isPointerInside else { return }
+ setPointerInside(false)
+ }
+
+ private func setup() {
+ wantsLayer = true
+ layer?.cornerRadius = 8
+ layer?.masksToBounds = true
+
+ titleBackgroundView.wantsLayer = true
+ controlsView.wantsLayer = true
+
+ titleLabel.isEditable = false
+ titleLabel.isSelectable = false
+ titleLabel.drawsBackground = false
+ titleLabel.isBordered = false
+ titleLabel.lineBreakMode = .byTruncatingTail
+ titleLabel.maximumNumberOfLines = 1
+ titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold)
+
+ detailLabel.isEditable = false
+ detailLabel.isSelectable = false
+ detailLabel.drawsBackground = false
+ detailLabel.isBordered = false
+ detailLabel.lineBreakMode = .byClipping
+ detailLabel.maximumNumberOfLines = 1
+ detailLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold)
+
+ detailScrollView.drawsBackground = false
+ detailScrollView.hasVerticalScroller = false
+ detailScrollView.hasHorizontalScroller = true
+ detailScrollView.autohidesScrollers = true
+ detailScrollView.borderType = .noBorder
+ detailScrollView.documentView = detailLabel
+
+ previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) }
+ nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) }
+
+ addSubview(titleBackgroundView)
+ titleBackgroundView.addSubview(titleLabel)
+ addSubview(detailScrollView)
+ addSubview(controlsView)
+ controlsView.addSubview(previousButton)
+ controlsView.addSubview(nextButton)
+ controlsView.addSubview(dotsView)
+
+ setAccessibilityRole(.group)
+ updateColors()
+ }
+
+ private func selectUsageTip(offset: Int) {
+ coordinator?.selectUsageTip(offset: offset)
+ applyCoordinatorState()
+ }
+
+ 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 textWidth = detailLabel.attributedStringValue.size().width
+ let width = max(detailScrollView.contentView.bounds.width, ceil(textWidth) + 36)
+ detailLabel.frame = NSRect(x: 18, y: 0, width: width, height: bounds.height)
+ }
+
+ private func updateColors() {
+ layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor
+ layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.18).cgColor
+ layer?.borderWidth = 1
+ titleBackgroundView.layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.96).cgColor
+ controlsView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor
+ titleLabel.textColor = .labelColor
+ detailLabel.textColor = NSColor.white.withAlphaComponent(0.96)
+ }
+}
+
+private final class AppGridUsageTipIconButton: NSView {
+ var action: (() -> Void)?
+ var accessibilityLabel: String = "" {
+ didSet {
+ setAccessibilityLabel(accessibilityLabel)
+ }
+ }
+
+ private let imageView = NSImageView()
+ private var trackingAreaRef: NSTrackingArea?
+ private var isHovered = false
+
+ override var isFlipped: Bool { true }
+ override var acceptsFirstResponder: Bool { false }
+
+ init(systemImage: String) {
+ super.init(frame: .zero)
+ setup(systemImage: systemImage)
+ }
+
+ required init?(coder: NSCoder) { fatalError() }
+
+ override func layout() {
+ super.layout()
+ let size: CGFloat = 24
+ 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
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ isHovered = false
+ needsDisplay = true
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ action?()
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard isHovered else { return }
+ NSColor.white.withAlphaComponent(0.12).setFill()
+ NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 7, yRadius: 7).fill()
+ }
+
+ private func setup(systemImage: String) {
+ wantsLayer = true
+ let config = NSImage.SymbolConfiguration(pointSize: 24, weight: .bold)
+ imageView.image = NSImage(
+ systemSymbolName: systemImage,
+ accessibilityDescription: nil
+ )?.withSymbolConfiguration(config)
+ imageView.imageScaling = .scaleProportionallyDown
+ imageView.contentTintColor = .white
+ addSubview(imageView)
+ setAccessibilityRole(.button)
+ }
+}
+
+private final class AppGridUsageTipDotsView: NSView {
+ private var count = 0
+ private var selectedIndex = 0
+
+ override var isFlipped: Bool { true }
+
+ 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 dotSize: CGFloat = 5
+ let spacing: CGFloat = 5
+ let totalWidth = CGFloat(count) * dotSize + CGFloat(max(0, count - 1)) * spacing
+ var x = (bounds.width - totalWidth) / 2
+ let y = (bounds.height - dotSize) / 2
+
+ for index in 0..<count {
+ let color = index == selectedIndex
+ ? NSColor.controlAccentColor
+ : NSColor.white.withAlphaComponent(0.58)
+ color.setFill()
+ NSBezierPath(
+ ovalIn: NSRect(x: x, y: y, width: dotSize, height: dotSize)
+ ).fill()
+ x += dotSize + spacing
+ }
+ }
+}
+
private final class AppGridContainerCollectionLayout: NSCollectionViewLayout {
weak var coordinator: AppGridCollectionView.Coordinator?
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 122addf..ed6f932 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -340,120 +340,6 @@
let tagName: String
}
-private struct AppGridUsageTip: Identifiable {
- let id: Int
- let titleKey: String
- let detailKey: String
-}
-
-private struct AppGridUsageTipsBar: View {
- static let reservedHeight: CGFloat = 64
-
- let tips: [AppGridUsageTip]
- @Binding var selectedIndex: Int
- let dragModeActive: Bool
-
- @State private var textHovered = false
-
- private var safeIndex: Int {
- guard tips.indices.contains(selectedIndex) else { return 0 }
- return selectedIndex
- }
-
- private var currentTip: AppGridUsageTip? {
- guard !tips.isEmpty else { return nil }
- return tips[safeIndex]
- }
-
- var body: some View {
- if let tip = currentTip {
- HStack(spacing: 0) {
- Text(tr(tip.titleKey))
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(Color.primary)
- .lineLimit(1)
- .minimumScaleFactor(0.82)
- .padding(.horizontal, 14)
- .frame(width: 174, height: 42, alignment: .leading)
- .background(Color(nsColor: .controlBackgroundColor).opacity(0.96))
-
- tipDetailText(tip)
- .frame(height: 42)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(Color.black.opacity(0.88))
-
- tipsControls
- .frame(width: 86, height: 42)
- .background(Color.black.opacity(0.88))
- }
- .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .stroke(Color.primary.opacity(0.18), lineWidth: 1)
- )
- .shadow(color: .black.opacity(0.20), radius: 14, y: 7)
- .frame(maxWidth: 980)
- .padding(.horizontal, 18)
- .padding(.bottom, 12)
- .opacity(dragModeActive ? 0.72 : 1.0)
- .allowsHitTesting(!dragModeActive)
- .accessibilityElement(children: .combine)
- .accessibilityLabel("\(tr(tip.titleKey)) \(tr(tip.detailKey))")
- }
- }
-
- private func tipDetailText(_ tip: AppGridUsageTip) -> some View {
- ScrollView(.horizontal, showsIndicators: textHovered) {
- Text(tr(tip.detailKey))
- .font(.system(size: 15, weight: .medium))
- .foregroundStyle(Color.white.opacity(0.96))
- .lineLimit(1)
- .fixedSize(horizontal: true, vertical: false)
- .padding(.horizontal, 14)
- .frame(minHeight: 42, alignment: .center)
- }
- .onHover { textHovered = $0 }
- .help(tr(tip.detailKey))
- }
-
- private var tipsControls: some View {
- VStack(spacing: 4) {
- HStack(spacing: 8) {
- usageTipNavigationButton(systemImage: "chevron.left", labelKey: "usageTips.previous") {
- selectedIndex = (safeIndex - 1 + tips.count) % tips.count
- }
- usageTipNavigationButton(systemImage: "chevron.right", labelKey: "usageTips.next") {
- selectedIndex = (safeIndex + 1) % tips.count
- }
- }
-
- HStack(spacing: 3) {
- ForEach(tips.indices, id: \.self) { index in
- Circle()
- .fill(index == safeIndex ? Color.accentColor : Color.white.opacity(0.58))
- .frame(width: 4, height: 4)
- }
- }
- }
- }
-
- private func usageTipNavigationButton(
- systemImage: String,
- labelKey: String,
- action: @escaping () -> Void
- ) -> some View {
- Button(action: action) {
- Image(systemName: systemImage)
- .font(.system(size: 15, weight: .bold))
- .foregroundStyle(Color.white.opacity(0.96))
- .frame(width: 24, height: 22)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityLabel(tr(labelKey))
- }
-}
-
struct ContentView: View {
let hideOverlay: () -> Void
private let initialQuickSearchSource: String?
@@ -468,6 +354,7 @@
@State private var appGridScrollTargetID: String? = nil
@State private var appGridScrollRequestToken = 0
@State private var selectedUsageTipIndex = 0
+ @State private var usageTipsHovered = false
// Edit mode
@State private var editPhase: EditPhase = .none
@@ -539,7 +426,8 @@
private let floatingControlsTrailingInset: CGFloat = 20
private let floatingControlsReservedWidth: CGFloat = 120
private var appBubbleDisabled: Bool {
- appGridInteraction.appDragModeActive
+ usageTipsHovered
+ || appGridInteraction.appDragModeActive
|| appGridInteraction.pendingUncategorizedDrop != nil
|| appGridInteraction.pendingTagRemovalDrop != nil
}
@@ -1245,50 +1133,43 @@
ProgressView().scaleEffect(0.8)
Spacer()
} else {
- ZStack(alignment: .bottom) {
- AppGridCollectionView(
- groups: displayGroups,
- tagColors: tagColors,
- displayMode: displayMode,
- iconSize: iconSize,
- showNames: !hideAppNames,
- bubbleDisabled: appBubbleDisabled,
- showUncommonAppBubbles: showUncommonAppBubbles,
- highlightedGroupName: appGridHighlightedGroupName,
- bottomContentPadding: shouldShowUsageTips ? AppGridUsageTipsBar.reservedHeight : 0,
- contentRevision: groupLayoutVersion,
- scrollTargetID: appGridScrollTargetID,
- scrollRequestToken: appGridScrollRequestToken,
- onSelectApp: { app in openApp(app) },
- onBubbleHover: handleBubbleHover,
- onEditNote: beginEditingBubbleNote,
- onDropApp: { path, source, target, copy in
- dropApp(path: path, sourceTag: source, targetTag: target, copy: copy)
- },
- onDropOutsideGroup: { path, source, copy in
- dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
- },
- onReorderApps: { containerID, orderedPaths in
- reorderApps(inContainer: containerID, orderedPaths: orderedPaths)
- },
- onGroupActivate: { groupName in
- if isColorlessContainerMode {
- toggleColorlessFill(groupName)
- }
- },
- onScrollActivity: handleAppGridScrollActivity,
- onDragModeChange: { setAppDragMode($0) }
- )
-
- if shouldShowUsageTips {
- AppGridUsageTipsBar(
- tips: appGridUsageTips,
- selectedIndex: $selectedUsageTipIndex,
- dragModeActive: appGridInteraction.appDragModeActive
- )
- .zIndex(3)
- }
- }
+ AppGridCollectionView(
+ groups: displayGroups,
+ tagColors: tagColors,
+ displayMode: displayMode,
+ iconSize: iconSize,
+ showNames: !hideAppNames,
+ bubbleDisabled: appBubbleDisabled,
+ showUncommonAppBubbles: showUncommonAppBubbles,
+ highlightedGroupName: appGridHighlightedGroupName,
+ bottomContentPadding: shouldShowUsageTips ? AppGridUsageTipsMetrics.reservedHeight : 0,
+ usageTipsVisible: shouldShowUsageTips,
+ usageTips: appGridUsageTips,
+ selectedUsageTipIndex: $selectedUsageTipIndex,
+ contentRevision: groupLayoutVersion,
+ scrollTargetID: appGridScrollTargetID,
+ scrollRequestToken: appGridScrollRequestToken,
+ onSelectApp: { app in openApp(app) },
+ onBubbleHover: handleBubbleHover,
+ onEditNote: beginEditingBubbleNote,
+ onDropApp: { path, source, target, copy in
+ dropApp(path: path, sourceTag: source, targetTag: target, copy: copy)
+ },
+ onDropOutsideGroup: { path, source, copy in
+ dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
+ },
+ onReorderApps: { containerID, orderedPaths in
+ reorderApps(inContainer: containerID, orderedPaths: orderedPaths)
+ },
+ onGroupActivate: { groupName in
+ if isColorlessContainerMode {
+ toggleColorlessFill(groupName)
+ }
+ },
+ onScrollActivity: handleAppGridScrollActivity,
+ onDragModeChange: { setAppDragMode($0) },
+ onUsageTipsHoverChange: handleUsageTipsHoverChange
+ )
}
}
}
@@ -2011,6 +1892,13 @@
}
}
+ private func handleUsageTipsHoverChange(_ hovering: Bool) {
+ usageTipsHovered = hovering
+ if hovering, appGridInteraction.hoveredBubble != nil {
+ appGridInteraction.hoveredBubble = nil
+ }
+ }
+
private func handleBubbleHover(app: AppInfo, frame: CGRect, event: AppBubbleHoverEvent) {
guard !appBubbleDisabled else {
clearAppBubbleState()
diff --git a/src/Scripts/usage_tips_qa.sh b/src/Scripts/usage_tips_qa.sh
index 474929e..7b5b0e8 100755
--- a/src/Scripts/usage_tips_qa.sh
+++ b/src/Scripts/usage_tips_qa.sh
@@ -78,36 +78,102 @@
"General settings must expose a Hide usage tips toggle",
)
require(
- r"\bprivate\s+struct\s+AppGridUsageTipsBar\s*:\s*View\b",
- content_view,
- "ContentView must define the app grid usage tips overlay",
+ r"\bprivate\s+final\s+class\s+AppGridUsageTipsNSView\s*:\s*NSView\b",
+ app_grid,
+ "Usage tips must be implemented as a native AppKit NSView",
)
require(
- r"static\s+let\s+reservedHeight\s*:\s*CGFloat\s*=\s*64",
- content_view,
- "Usage tips overlay must reserve stable bottom space",
+ r"enum\s+AppGridUsageTipsMetrics\s*\{(?P<body>.*?)static\s+let\s+reservedHeight\s*:\s*CGFloat\s*=\s*104",
+ app_grid,
+ "Usage tips overlay must reserve stable bottom space for the larger native bar",
)
require(
- r"ScrollView\s*\(\s*\.horizontal\s*,\s*showsIndicators\s*:\s*textHovered\s*\)",
- content_view,
- "Long localized tip text must be horizontally scrollable on hover",
+ r"detailScrollView\.hasHorizontalScroller\s*=\s*true",
+ app_grid,
+ "Long localized tip text must be horizontally scrollable in the native AppKit bar",
+)
+if len(re.findall(r"NSFont\.systemFont\s*\(\s*ofSize\s*:\s*24\s*,\s*weight\s*:\s*\.bold\s*\)", app_grid)) < 2:
+ fail("Usage tip title/detail fonts must use the larger 24pt native AppKit style")
+require(
+ r"override\s+func\s+scrollWheel\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)\s*\{\s*detailScrollView\.scrollWheel\s*\(\s*with\s*:\s*event\s*\)",
+ app_grid,
+ "Usage tips must route wheel events to the native horizontal text scroller",
)
require(
- r"\.onHover\s*\{\s*textHovered\s*=\s*\$0\s*\}",
- content_view,
- "Usage tip text must react to hover so long text can be inspected",
-)
-require(
- r"bottomContentPadding\s*:\s*shouldShowUsageTips\s*\?\s*AppGridUsageTipsBar\.reservedHeight\s*:\s*0",
+ r"bottomContentPadding\s*:\s*shouldShowUsageTips\s*\?\s*AppGridUsageTipsMetrics\.reservedHeight\s*:\s*0",
content_view,
"AppGrid must reserve bottom content space when tips are visible",
)
require(
- r"\.allowsHitTesting\s*\(\s*!dragModeActive\s*\)",
+ r"usageTipsVisible\s*:\s*shouldShowUsageTips",
content_view,
- "Usage tips overlay must stop intercepting drag/drop while app drag mode is active",
+ "ContentView must pass visibility into the native AppKit usage tips bar",
)
require(
+ r"selectedUsageTipIndex\s*:\s*\$selectedUsageTipIndex",
+ content_view,
+ "ContentView must bind usage tip selection to the native AppKit bar",
+)
+require(
+ r"override\s+func\s+hitTest\s*\(\s*_\s+point\s*:\s*NSPoint\s*\)\s*->\s*NSView\?\s*\{(?P<body>.*?)bounds\.contains\s*\(\s*point\s*\)",
+ app_grid,
+ "Native usage tips bar must own hit testing so clicks do not fall through to AppGrid",
+)
+require(
+ r"override\s+func\s+mouseDown\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)",
+ app_grid,
+ "Native usage tips bar must swallow mouseDown events instead of letting clicks close the grid",
+)
+require(
+ r"private\s+final\s+class\s+AppGridUsageTipIconButton\s*:\s*NSView\b",
+ app_grid,
+ "Usage tip navigation controls must be native AppKit hit-testable views",
+)
+require(
+ r"func\s+selectUsageTip\s*\(\s*offset\s*:\s*Int\s*\)",
+ app_grid,
+ "Usage tip navigation must update the selected tip index from AppKit",
+)
+require(
+ r"previousButton\.action\s*=\s*\{\s*\[weak\s+self\]\s+in\s+self\?\.selectUsageTip\s*\(\s*offset\s*:\s*-1\s*\)\s*\}",
+ app_grid,
+ "Previous usage tip control must call the native selection handler",
+)
+require(
+ r"nextButton\.action\s*=\s*\{\s*\[weak\s+self\]\s+in\s+self\?\.selectUsageTip\s*\(\s*offset\s*:\s*1\s*\)\s*\}",
+ app_grid,
+ "Next usage tip control must call the native selection handler",
+)
+require(
+ r"override\s+func\s+mouseDown\s*\(\s*with\s+event\s*:\s*NSEvent\s*\)\s*\{\s*action\?\(\)\s*\}",
+ app_grid,
+ "Native usage tip icon buttons must invoke their action directly on mouseDown",
+)
+require(
+ r"onUsageTipIndexChange\s*\(\s*nextIndex\s*\)",
+ app_grid,
+ "Native usage tip selection must publish the new index back to SwiftUI state",
+)
+require(
+ r"setUsageTipsBubbleDisabled\s*\(\s*inside\s*\)",
+ app_grid,
+ "Hovering usage tips must suppress lower AppGrid bubbles",
+)
+require(
+ r"onUsageTipsHoverChange\s*\(\s*inside\s*\)",
+ app_grid,
+ "Hovering usage tips must notify ContentView to clear already visible SwiftUI bubbles",
+)
+require(
+ r"private\s+func\s+handleUsageTipsHoverChange\s*\(\s*_\s+hovering\s*:\s*Bool\s*\)",
+ content_view,
+ "ContentView must clear existing app bubbles while the native usage tips bar is hovered",
+)
+if re.search(r"AppGridUsageTipsBar\s*:\s*View|usageTipNavigationButton|ScrollView\s*\(\s*\.horizontal\s*,\s*showsIndicators\s*:\s*textHovered", content_view):
+ fail("usage tips must not be implemented with SwiftUI views in ContentView")
+if re.search(r"Text\s*\(\s*tr\s*\(\s*tip\.(?:titleKey|detailKey)", content_view):
+ fail("usage tip title/detail rendering must not use SwiftUI Text")
+require(
r"let\s+bottomContentPadding\s*:\s*CGFloat",
app_grid,
"AppGridCollectionView must accept bottom content padding",
--
Gitblit v1.9.3