| | |
| | | static let theme = "theme" |
| | | static let tags = "tags" |
| | | static let data = "data" |
| | | static let pro = "pro" |
| | | static let about = "about" |
| | | } |
| | | |
| | |
| | | |
| | | // 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 |
| | | |
| | |
| | | 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() |
| | | } |
| | | } |
| | | |
| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | let id = UUID() |
| | | let feature: ProFeature |
| | | let noteQuotaStatus: ProNoteQuotaStatus? |
| | | let customContainerQuotaStatus: ProCustomContainerQuotaStatus? |
| | | } |
| | | |
| | | struct ContentView: View { |
| | |
| | | @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 |
| | |
| | | ) |
| | | } |
| | | |
| | | private var renderedDisplayMode: String { |
| | | ProEntitlementPolicy.effectiveDisplayMode(for: displayMode) |
| | | } |
| | | |
| | | private func storedThemeTuning(for theme: AppGridTheme) -> Double? { |
| | | switch theme { |
| | | case .deepBlue: |
| | |
| | | } |
| | | |
| | | private var isColorlessContainerMode: Bool { |
| | | displayMode == "container" || displayMode == "gridContainer" |
| | | renderedDisplayMode == "container" || renderedDisplayMode == "gridContainer" |
| | | } |
| | | |
| | | private var shouldShowUsageTips: Bool { |
| | |
| | | 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) |
| | |
| | | // 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 { |
| | |
| | | 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: { |
| | |
| | | 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: { |
| | |
| | | AppGridCollectionView( |
| | | groups: displayGroups, |
| | | tagColors: tagColors, |
| | | displayMode: displayMode, |
| | | tagCustomColors: tagCustomColors, |
| | | displayMode: renderedDisplayMode, |
| | | iconSize: iconSize, |
| | | showNames: !hideAppNames, |
| | | appGridTheme: renderedAppGridTheme, |
| | |
| | | |
| | | 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 |
| | |
| | | .onEnded { _ in |
| | | guard canReorderTag(tagName) else { return } |
| | | dragItem = nil |
| | | TagEditor.reorderTags(draggedTagNames) |
| | | persistTagOrderIfAllowed() |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | 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 = [] |
| | |
| | | } |
| | | |
| | | 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] { |
| | |
| | | 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 |
| | | } |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | 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() { |
| | |
| | | } |
| | | |
| | | 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) |
| | | } |
| | |
| | | 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) |
| | | } |
| | |
| | | |
| | | 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) |
| | | } |
| | |
| | | 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) |
| | | } |
| | |
| | | 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() |