From 0ae3a9359e4cdf6ae5b669c4affd91662463f370 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 01 Jul 2026 23:17:36 +0800
Subject: [PATCH] Prepare TagLauncher 8.3.1 candidate
---
src/Apptag/ContentView.swift | 223 ++++++++++++++++++++++++++++++++++++++++++++++++-------
1 files changed, 193 insertions(+), 30 deletions(-)
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 743ec5f..3fede8e 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -19,6 +19,7 @@
static let theme = "theme"
static let tags = "tags"
static let data = "data"
+ static let pro = "pro"
static let about = "about"
}
@@ -37,8 +38,8 @@
// MARK: - Native NSTextField (avoids SwiftUI TextField event issues)
-/// Custom container that wraps NSTextField so that hitTest returns the container
-/// and mouseDown can reliably make the text field first responder.
+/// Custom container that wraps NSTextField so first focus is reliable while
+/// subsequent clicks keep native caret placement and selection behavior.
final class TextFieldContainer: NSView {
let textField: NSTextField
@@ -57,14 +58,33 @@
textField.frame = bounds
}
+ private var isEditing: Bool {
+ guard let window else { return false }
+ return window.firstResponder === textField || textField.currentEditor() != nil
+ }
+
override func hitTest(_ point: NSPoint) -> NSView? {
- if bounds.contains(point) { return self }
- return nil
+ guard bounds.contains(point) else { return nil }
+ if isEditing {
+ let fieldPoint = textField.convert(point, from: self)
+ return textField.hitTest(fieldPoint) ?? textField
+ }
+ return self
+ }
+
+ func focusTextField() {
+ guard let w = window else { return }
+ let shouldSelectAll = !isEditing
+ w.makeFirstResponder(textField)
+ if shouldSelectAll {
+ textField.selectText(nil)
+ }
}
override func mouseDown(with event: NSEvent) {
- if let w = window { w.makeFirstResponder(textField) }
- textField.mouseDown(with: event)
+ // Do not forward this same NSEvent to NSTextField.mouseDown. AppKit can
+ // forward it back to this container during control tracking and recurse.
+ focusTextField()
}
}
@@ -214,19 +234,21 @@
struct TagPill: View {
let name: String
let colorIndex: Int
+ var customColor: TagCustomColor? = nil
var dragModeActive: Bool = false
var isDragging: Bool = false
let action: () -> Void
+ private var resolvedNSColor: NSColor {
+ TagColor.nsColor(for: colorIndex, customColor: customColor)
+ }
+
private var bgColor: Color {
- Color(nsColor: TagColor.nsColor(for: colorIndex))
+ Color(nsColor: resolvedNSColor)
}
private var textColor: Color {
- if colorIndex == 0 || colorIndex == 5 {
- return .primary
- }
- return .white
+ TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
}
var body: some View {
@@ -253,15 +275,21 @@
struct SideTagPill: View {
let name: String
let colorIndex: Int
+ var customColor: TagCustomColor? = nil
var dragModeActive: Bool = false
var isDragging: Bool = false
let action: () -> Void
- private var bgColor: Color {
- Color(nsColor: TagColor.nsColor(for: colorIndex))
+ private var resolvedNSColor: NSColor {
+ TagColor.nsColor(for: colorIndex, customColor: customColor)
}
+
+ private var bgColor: Color {
+ Color(nsColor: resolvedNSColor)
+ }
+
private var textColor: Color {
- colorIndex == 0 || colorIndex == 5 ? .primary : .white
+ TagColor.prefersDarkText(for: resolvedNSColor) ? .primary : .white
}
var body: some View {
@@ -365,6 +393,7 @@
let id = UUID()
let feature: ProFeature
let noteQuotaStatus: ProNoteQuotaStatus?
+ let customContainerQuotaStatus: ProCustomContainerQuotaStatus?
}
struct ContentView: View {
@@ -376,7 +405,9 @@
@State private var allApps: [AppInfo] = []
@State private var displayGroups: [TagGroup] = []
@State private var tagColors: [String: Int] = [:]
+ @State private var tagCustomColors: [String: TagCustomColor] = [:]
@State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
+ @State private var customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus()
@State private var containerAppOrder: [String: [String]] = [:]
@State private var groupLayoutVersion = 0
@State private var appGridScrollTargetID: String? = nil
@@ -499,6 +530,10 @@
)
}
+ private var renderedDisplayMode: String {
+ ProEntitlementPolicy.effectiveDisplayMode(for: displayMode)
+ }
+
private func storedThemeTuning(for theme: AppGridTheme) -> Double? {
switch theme {
case .deepBlue:
@@ -519,7 +554,7 @@
}
private var isColorlessContainerMode: Bool {
- displayMode == "container" || displayMode == "gridContainer"
+ renderedDisplayMode == "container" || renderedDisplayMode == "gridContainer"
}
private var shouldShowUsageTips: Bool {
@@ -683,11 +718,11 @@
benefitText: tr(prompt.feature.benefitKey),
operationText: proEntitlement.operationState.statusMessageKey.map(tr),
unlockTitle: tr("pro.card.unlock"),
- restoreTitle: tr("pro.card.restore"),
+ restoreTitle: proPromptSecondaryTitle(for: prompt),
isBusy: proEntitlement.operationState.isBusy,
onClose: dismissProPrompt,
onUnlock: { proEntitlement.purchasePro() },
- onRestore: { proEntitlement.restorePurchases() }
+ onRestore: { handleProPromptSecondaryAction(prompt) }
)
}
.transition(.opacity)
@@ -740,7 +775,10 @@
// Always sync tag list from database when entering edit mode
let store = TagDatabase.load()
tagColors = store.tags.mapValues { $0.color }
+ tagCustomColors = store.tags.compactMapValues { $0.customColor }
+ tagDefinitions = store.tags
draggedTagNames = TagEditor.orderedTagNames()
+ refreshCustomContainerQuotaStatus(in: store)
}
editPhase = phase
if phase == .none {
@@ -1185,6 +1223,7 @@
HStack(spacing: 8) {
ForEach(tagLabels) { tag in
TagPill(name: tag.name, colorIndex: tag.colorIndex,
+ customColor: tag.customColor,
dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
isDragging: tagNavDragItem == tag.name,
action: {
@@ -1212,6 +1251,7 @@
VStack(spacing: 6) {
ForEach(tagLabels) { tag in
SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
+ customColor: tag.customColor,
dragModeActive: tagNavDragModeActive && canReorderTag(tag.name),
isDragging: tagNavDragItem == tag.name,
action: {
@@ -1249,7 +1289,8 @@
AppGridCollectionView(
groups: displayGroups,
tagColors: tagColors,
- displayMode: displayMode,
+ tagCustomColors: tagCustomColors,
+ displayMode: renderedDisplayMode,
iconSize: iconSize,
showNames: !hideAppNames,
appGridTheme: renderedAppGridTheme,
@@ -1376,10 +1417,51 @@
TagEditorView(
tagColors: $tagColors,
+ tagCustomColors: $tagCustomColors,
excludedTagNames: ["Mac自带", defaultGroupName],
- onRefresh: { refreshApps() }
+ isCustomColorUnlocked: proEntitlement.isUnlocked,
+ onLockedCustomColor: { presentProPrompt(for: .customTagColors) },
+ onCustomContainerQuotaExceeded: presentCustomContainerQuotaPrompt,
+ onCustomContainerQuotaStatusChanged: refreshCustomContainerQuotaStatus,
+ topLeadingAccessory: AnyView(customContainerQuotaAccessory),
+ onRefresh: {
+ refreshCustomContainerQuotaStatus()
+ refreshApps()
+ }
)
}
+ }
+
+ private var effectiveCustomContainerQuotaStatus: ProCustomContainerQuotaStatus {
+ if proEntitlement.isUnlocked {
+ return ProCustomContainerQuotaStatus(isUnlimited: true, used: 0, limit: Int.max)
+ }
+ return customContainerQuotaStatus
+ }
+
+ private var customContainerQuotaAccessory: some View {
+ let status = effectiveCustomContainerQuotaStatus
+ let text = status.isUnlimited
+ ? tr("pro.customContainers.quotaUnlimitedShort")
+ : tr(
+ "pro.customContainers.quotaShort",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)"
+ ]
+ )
+ return Text(text)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ private func refreshCustomContainerQuotaStatus(_ status: ProCustomContainerQuotaStatus) {
+ customContainerQuotaStatus = status
+ }
+
+ private func refreshCustomContainerQuotaStatus(in store: TagDatabase.Store? = nil) {
+ customContainerQuotaStatus = ProEntitlementPolicy.customContainerQuotaStatus(in: store)
}
// MARK: - Edit Apps View
@@ -1531,7 +1613,7 @@
.onEnded { _ in
guard canReorderTag(tagName) else { return }
dragItem = nil
- TagEditor.reorderTags(draggedTagNames)
+ persistTagOrderIfAllowed()
}
}
@@ -1559,9 +1641,11 @@
switch editTagOperation {
case .add:
- TagEditor.appendTags(tags, to: paths)
+ let result = TagEditor.appendTags(tags, to: paths)
+ guard handleTagMutationResult(result) else { return }
case .remove:
- TagEditor.removeTags(tags, from: paths)
+ let result = TagEditor.removeTags(tags, from: paths)
+ guard handleTagMutationResult(result) else { return }
}
selectedAppPaths = []
selectedTagNames = []
@@ -2009,7 +2093,13 @@
}
private var tagLabels: [TagNavigationItem] {
- displayGroups.map { TagNavigationItem(name: $0.name, colorIndex: tagColors[$0.name] ?? 0) }
+ displayGroups.map {
+ TagNavigationItem(
+ name: $0.name,
+ colorIndex: tagColors[$0.name] ?? 0,
+ customColor: tagCustomColors[$0.name]
+ )
+ }
}
private var editGroups: [TagGroup] {
@@ -2203,16 +2293,68 @@
status.remaining <= 0 {
return tr("pro.notes.limitReached")
}
+ if prompt.feature == .customContainerQuota,
+ let status = prompt.customContainerQuotaStatus,
+ !status.isUnlimited {
+ return tr(
+ "pro.customContainers.quotaPrompt",
+ replacements: [
+ "%used%": "\(status.used)",
+ "%limit%": "\(status.limit)",
+ "%remaining%": "\(status.remaining)"
+ ]
+ )
+ }
return tr(prompt.feature.promptMessageKey)
}
- private func presentProPrompt(for feature: ProFeature, noteQuotaStatus: ProNoteQuotaStatus? = nil) {
+ private func proPromptSecondaryTitle(for prompt: PendingProPrompt) -> String {
+ prompt.feature == .customContainerQuota
+ ? tr("pro.customContainers.manageExisting")
+ : tr("pro.card.restore")
+ }
+
+ private func handleProPromptSecondaryAction(_ prompt: PendingProPrompt) {
+ if prompt.feature == .customContainerQuota {
+ dismissProPrompt()
+ setEditPhase(.editingTags)
+ return
+ }
+ proEntitlement.restorePurchases()
+ }
+
+ private func presentProPrompt(
+ for feature: ProFeature,
+ noteQuotaStatus: ProNoteQuotaStatus? = nil,
+ customContainerQuotaStatus: ProCustomContainerQuotaStatus? = nil
+ ) {
proEntitlement.clearTransientOperationState()
withAnimation(.spring(response: 0.22, dampingFraction: 0.84)) {
appGridInteraction.proPrompt = PendingProPrompt(
feature: feature,
- noteQuotaStatus: noteQuotaStatus
+ noteQuotaStatus: noteQuotaStatus,
+ customContainerQuotaStatus: customContainerQuotaStatus
)
+ }
+ }
+
+ private func presentCustomContainerQuotaPrompt(_ status: ProCustomContainerQuotaStatus) {
+ presentProPrompt(
+ for: .customContainerQuota,
+ customContainerQuotaStatus: status
+ )
+ }
+
+ private func handleTagMutationResult(_ result: TagEditorMutationResult) -> Bool {
+ switch result {
+ case .saved:
+ refreshCustomContainerQuotaStatus()
+ return true
+ case .noChange:
+ return false
+ case .blockedCustomContainerQuota(let status):
+ presentCustomContainerQuotaPrompt(status)
+ return false
}
}
@@ -2229,6 +2371,10 @@
if let preview = proEntitlement.themePreviewState {
appGridThemeID = preview.theme.rawValue
proEntitlement.stopThemePreview()
+ }
+ if let preview = proEntitlement.displayModePreviewState {
+ displayMode = preview.displayMode
+ proEntitlement.stopDisplayModePreview()
}
let hadPrompt = appGridInteraction.proPrompt != nil
@@ -2498,11 +2644,22 @@
let hadDragState = tagNavDragModeActive || tagNavDragItem != nil
guard hadDragState else { return }
if tagNavDragModeActive && tagNavReorderDidMove {
- TagEditor.reorderTags(draggedTagNames)
+ persistTagOrderIfAllowed()
}
tagNavDragModeActive = false
tagNavDragItem = nil
tagNavReorderDidMove = false
+ }
+
+ private func persistTagOrderIfAllowed() {
+ guard proEntitlement.isUnlocked else {
+ showDropWarning(message: tr("pro.tagSorting.previewToast"))
+ return
+ }
+ guard TagEditor.reorderTags(draggedTagNames) else {
+ showDropWarning(message: tr("pro.tagSorting.previewToast"))
+ return
+ }
}
private func cancelTagNavReorderVisualState() {
@@ -2550,13 +2707,14 @@
}
guard tagColors[targetTag] != nil else { return }
- TagEditor.moveApp(
+ let result = TagEditor.moveApp(
path: path,
from: sourceTag,
to: targetTag,
color: tagColors[targetTag] ?? 0,
copy: copy
)
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2566,7 +2724,8 @@
guard canAssignDroppedAppToTag(targetTag) else { return }
guard let app = allApps.first(where: { $0.path.path == path }) else { return }
guard !appHasTag(app, tagName: targetTag) else { return }
- TagEditor.appendTags([targetTag], to: [path])
+ let result = TagEditor.appendTags([targetTag], to: [path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2703,7 +2862,8 @@
private func moveDroppedAppToUncategorized(path: String, tags: [String]) {
guard !tags.isEmpty else { return }
- TagEditor.removeTags(tags, from: [path])
+ let result = TagEditor.removeTags(tags, from: [path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2770,7 +2930,8 @@
guard isRemovableRegularTag(tagName),
appHasTag(app, tagName: tagName)
else { return }
- TagEditor.removeTags([tagName], from: [app.path.path])
+ let result = TagEditor.removeTags([tagName], from: [app.path.path])
+ guard handleTagMutationResult(result) else { return }
showDropRefresh()
refreshApps(forceLayoutRefresh: true)
}
@@ -2946,8 +3107,10 @@
quickSearchDocuments = snapshot.quickSearchDocuments
tagColors = snapshot.tagColors
tagDefinitions = snapshot.tagDefinitions
+ tagCustomColors = snapshot.tagDefinitions.compactMapValues { $0.customColor }
containerAppOrder = snapshot.containerAppOrder
draggedTagNames = snapshot.tagOrder
+ refreshCustomContainerQuotaStatus()
rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
if quickSearchVisible {
refreshQuickSearchResults()
--
Gitblit v1.9.3