From 18a0fe3977175f3f7a945b300524fd2cd37fef28 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 15:15:47 +0800
Subject: [PATCH] Add app grid usage tips
---
src/Apptag/ContentView.swift | 271 ++++++++++++++++++++++++++++++++++++++++++++----------
1 files changed, 220 insertions(+), 51 deletions(-)
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index e2bf1c7..122addf 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -319,8 +319,6 @@
var bubbleDraftNote = ""
var tagNavigationHoveredGroupName: String? = nil
var tagNavigationAppDropTargetName: String? = nil
- var tagNavigationLastHoverScrollID: String? = nil
- var tagNavigationLastHoverScrollAt: Date? = nil
}
private struct EditActionFeedback: Identifiable {
@@ -342,6 +340,120 @@
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?
@@ -350,9 +462,12 @@
@State private var allApps: [AppInfo] = []
@State private var displayGroups: [TagGroup] = []
@State private var tagColors: [String: Int] = [:]
+ @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
+ @State private var containerAppOrder: [String: [String]] = [:]
@State private var groupLayoutVersion = 0
@State private var appGridScrollTargetID: String? = nil
@State private var appGridScrollRequestToken = 0
+ @State private var selectedUsageTipIndex = 0
// Edit mode
@State private var editPhase: EditPhase = .none
@@ -414,6 +529,7 @@
@AppStorage("displayMode") private var displayMode = AppDefaults.displayMode
@AppStorage("hideAppNames") private var hideAppNames = AppDefaults.hideAppNames
@AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+ @AppStorage("hideUsageTips") private var hideUsageTips = AppDefaults.hideUsageTips
@AppStorage("useAppKitTagNavigation") private var useAppKitTagNavigation = AppDefaults.useAppKitTagNavigation
@AppStorage("skipTagRemovalDropConfirm") private var skipTagRemovalDropConfirm = false
@AppStorage("skipUncategorizedDropConfirm") private var skipUncategorizedDropConfirm = false
@@ -432,8 +548,6 @@
?? (isColorlessContainerMode ? filledColorlessContainer : nil)
}
private let rightSidebarFloatingClearance: CGFloat = 44
- private let tagNavigationHoverScrollInterval: TimeInterval = 0.22
-
private var floatingButtonSurfaceColor: Color {
colorScheme == .dark
? Color.white.opacity(0.10)
@@ -450,6 +564,23 @@
private var isColorlessContainerMode: Bool {
displayMode == "container" || displayMode == "gridContainer"
+ }
+
+ private var shouldShowUsageTips: Bool {
+ !hideUsageTips && !allApps.isEmpty && !quickSearchOnlySession
+ }
+
+ private var appGridUsageTips: [AppGridUsageTip] {
+ [
+ AppGridUsageTip(id: 1, titleKey: "usageTips.tip1.title", detailKey: "usageTips.tip1.detail"),
+ AppGridUsageTip(id: 2, titleKey: "usageTips.tip2.title", detailKey: "usageTips.tip2.detail"),
+ AppGridUsageTip(id: 3, titleKey: "usageTips.tip3.title", detailKey: "usageTips.tip3.detail"),
+ AppGridUsageTip(id: 4, titleKey: "usageTips.tip4.title", detailKey: "usageTips.tip4.detail"),
+ AppGridUsageTip(id: 5, titleKey: "usageTips.tip5.title", detailKey: "usageTips.tip5.detail"),
+ AppGridUsageTip(id: 6, titleKey: "usageTips.tip6.title", detailKey: "usageTips.tip6.detail"),
+ AppGridUsageTip(id: 7, titleKey: "usageTips.tip7.title", detailKey: "usageTips.tip7.detail"),
+ AppGridUsageTip(id: 8, titleKey: "usageTips.tip8.title", detailKey: "usageTips.tip8.detail"),
+ ]
}
private var shouldRenderAppGridBehindQuickSearch: Bool {
@@ -623,6 +754,9 @@
.onChange(of: appGridInteraction.pendingTagRemovalDrop != nil) { _, _ in
publishModalInteractionState()
}
+ .onChange(of: smartStartNotice != nil) { _, _ in
+ publishModalInteractionState()
+ }
.onDisappear {
NotificationCenter.default.post(
name: .tagLauncherModalInteractionChanged,
@@ -636,7 +770,11 @@
NotificationCenter.default.post(
name: .tagLauncherModalInteractionChanged,
object: nil,
- userInfo: ["active": appGridInteraction.pendingUncategorizedDrop != nil || appGridInteraction.pendingTagRemovalDrop != nil]
+ userInfo: [
+ "active": smartStartNotice != nil
+ || appGridInteraction.pendingUncategorizedDrop != nil
+ || appGridInteraction.pendingTagRemovalDrop != nil
+ ]
)
}
@@ -1107,35 +1245,50 @@
ProgressView().scaleEffect(0.8)
Spacer()
} else {
- AppGridCollectionView(
- groups: displayGroups,
- tagColors: tagColors,
- displayMode: displayMode,
- iconSize: iconSize,
- showNames: !hideAppNames,
- bubbleDisabled: appBubbleDisabled,
- showUncommonAppBubbles: showUncommonAppBubbles,
- highlightedGroupName: appGridHighlightedGroupName,
- 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)
- },
- onGroupActivate: { groupName in
- if isColorlessContainerMode {
- toggleColorlessFill(groupName)
- }
- },
- onScrollActivity: handleAppGridScrollActivity,
- onDragModeChange: { setAppDragMode($0) }
- )
+ 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)
+ }
+ }
}
}
}
@@ -1775,13 +1928,27 @@
private func makeDisplayGroups(apps: [AppInfo], tagOrder: [String]) -> [TagGroup] {
let order = tagOrder.isEmpty ? TagEditor.orderedTagNames() : tagOrder
- let rawGroups = AppIndexer.group(apps: apps, defaultGroupName: defaultGroupName, tagOrder: order)
+ let rawGroups = AppIndexer.group(
+ apps: apps,
+ defaultGroupName: defaultGroupName,
+ tagOrder: order,
+ tagDefinitions: tagDefinitions,
+ containerAppOrder: containerAppOrder
+ )
return rawGroups.map { group in
if group.name == defaultGroupName {
- return TagGroup(name: tr("group.uncategorized"), apps: group.apps)
+ return TagGroup(
+ containerID: group.containerID,
+ name: tr("group.uncategorized"),
+ apps: group.apps
+ )
}
if group.name == "Mac自带" {
- return TagGroup(name: tr("group.appleBuiltIn"), apps: group.apps)
+ return TagGroup(
+ containerID: group.containerID,
+ name: tr("group.appleBuiltIn"),
+ apps: group.apps
+ )
}
return group
}
@@ -2001,7 +2168,6 @@
appGridInteraction.tagNavigationHoveredGroupName = id
fillColorlessContainer(id)
- scrollToTagFromHover(id)
}
private func handleTagNavigationAppDropHover(_ id: String, active: Bool) {
@@ -2020,18 +2186,6 @@
}
appGridInteraction.tagNavigationAppDropTargetName = id
- }
-
- private func scrollToTagFromHover(_ id: String) {
- let now = Date()
- if appGridInteraction.tagNavigationLastHoverScrollID == id,
- let lastScrollAt = appGridInteraction.tagNavigationLastHoverScrollAt,
- now.timeIntervalSince(lastScrollAt) < tagNavigationHoverScrollInterval {
- return
- }
- appGridInteraction.tagNavigationLastHoverScrollID = id
- appGridInteraction.tagNavigationLastHoverScrollAt = now
- scrollTo(id)
}
private func fillColorlessContainer(_ id: String) {
@@ -2136,6 +2290,8 @@
private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
resetTransientDragState(keepingPendingUncategorizedDrop: true)
+ guard sourceTag != targetTag else { return }
+
if isUncategorizedDropTarget(targetTag) {
confirmAndMoveAppToUncategorized(path: path)
return
@@ -2147,7 +2303,6 @@
}
guard tagColors[targetTag] != nil else { return }
- guard sourceTag != targetTag || copy else { return }
TagEditor.moveApp(
path: path,
from: sourceTag,
@@ -2166,6 +2321,18 @@
guard !appHasTag(app, tagName: targetTag) else { return }
TagEditor.appendTags([targetTag], to: [path])
showDropRefresh()
+ refreshApps(forceLayoutRefresh: true)
+ }
+
+ private func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
+ resetTransientDragState()
+ let key = TagDatabase.normalizedContainerID(containerID, tags: tagDefinitions)
+ let paths = TagDatabase.normalizedAppOrderPaths(orderedPaths)
+ guard !key.isEmpty, !paths.isEmpty else { return }
+
+ containerAppOrder[key] = paths
+ rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+ TagEditor.reorderApps(inContainer: key, orderedPaths: paths)
refreshApps(forceLayoutRefresh: true)
}
@@ -2472,6 +2639,8 @@
allApps = snapshot.apps
quickSearchDocuments = snapshot.quickSearchDocuments
tagColors = snapshot.tagColors
+ tagDefinitions = snapshot.tagDefinitions
+ containerAppOrder = snapshot.containerAppOrder
draggedTagNames = snapshot.tagOrder
rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
if quickSearchVisible {
--
Gitblit v1.9.3