| | |
| | | |
| | | // MARK: - Tag Color Mapping |
| | | |
| | | struct TagCustomColor: Codable, Equatable { |
| | | var red: Double |
| | | var green: Double |
| | | var blue: Double |
| | | var alpha: Double |
| | | |
| | | init(red: Double, green: Double, blue: Double, alpha: Double = 1.0) { |
| | | self.red = Self.clamp(red) |
| | | self.green = Self.clamp(green) |
| | | self.blue = Self.clamp(blue) |
| | | self.alpha = Self.clamp(alpha) |
| | | } |
| | | |
| | | init(nsColor: NSColor) { |
| | | let rgbColor = nsColor.usingColorSpace(.sRGB) ?? nsColor |
| | | self.init( |
| | | red: Double(rgbColor.redComponent), |
| | | green: Double(rgbColor.greenComponent), |
| | | blue: Double(rgbColor.blueComponent), |
| | | alpha: Double(rgbColor.alphaComponent) |
| | | ) |
| | | } |
| | | |
| | | var nsColor: NSColor { |
| | | NSColor( |
| | | srgbRed: CGFloat(red), |
| | | green: CGFloat(green), |
| | | blue: CGFloat(blue), |
| | | alpha: CGFloat(alpha) |
| | | ) |
| | | } |
| | | |
| | | var signature: String { |
| | | [ |
| | | String(format: "%.4f", red), |
| | | String(format: "%.4f", green), |
| | | String(format: "%.4f", blue), |
| | | String(format: "%.4f", alpha) |
| | | ].joined(separator: ":") |
| | | } |
| | | |
| | | private static func clamp(_ value: Double) -> Double { |
| | | min(1.0, max(0.0, value)) |
| | | } |
| | | } |
| | | |
| | | enum TagColor { |
| | | static func nsColor(for index: Int) -> NSColor { |
| | | switch index { |
| | |
| | | default: return NSColor.systemGray |
| | | } |
| | | } |
| | | |
| | | static func nsColor(for index: Int, customColor: TagCustomColor?) -> NSColor { |
| | | customColor?.nsColor ?? nsColor(for: index) |
| | | } |
| | | |
| | | static func prefersDarkText(for color: NSColor) -> Bool { |
| | | let rgbColor = color.usingColorSpace(.sRGB) ?? color |
| | | let red = Double(rgbColor.redComponent) |
| | | let green = Double(rgbColor.greenComponent) |
| | | let blue = Double(rgbColor.blueComponent) |
| | | let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue) |
| | | return luminance > 0.62 |
| | | } |
| | | |
| | | static let allIndices: [Int] = [1, 2, 3, 4, 5, 6, 7] |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | for tagName in orderedTagDefinitionNames( |
| | | tagDefinitions: effectiveTagDefinitions, |
| | | tagOrder: tagOrder |
| | | ) { |
| | | let displayName = nameOverrides[tagName] ?? tagName |
| | | guard displayName != macCategory, |
| | | displayName != defaultGroupName |
| | | else { continue } |
| | | |
| | | if containerIDsByGroupName[displayName] == nil { |
| | | containerIDsByGroupName[displayName] = AppContainerID.forTag( |
| | | tagName, |
| | | definition: effectiveTagDefinitions[tagName] |
| | | ) |
| | | } |
| | | if dict[displayName] == nil { |
| | | dict[displayName] = [] |
| | | } |
| | | } |
| | | |
| | | // Build sort index from tagOrder: lower index = appears first |
| | | var orderIndex: [String: Int] = [:] |
| | | for (i, name) in tagOrder.enumerated() { |
| | |
| | | return ordered + remaining |
| | | } |
| | | |
| | | private static func orderedTagDefinitionNames( |
| | | tagDefinitions: [String: TagDatabase.TagDef], |
| | | tagOrder: [String] |
| | | ) -> [String] { |
| | | let ordered = tagOrder.filter { tagDefinitions[$0] != nil } |
| | | let orderedSet = Set(ordered) |
| | | let remaining = tagDefinitions.keys |
| | | .filter { !orderedSet.contains($0) } |
| | | .sorted() |
| | | return ordered + remaining |
| | | } |
| | | |
| | | private static func appendGroupedApp( |
| | | _ app: AppInfo, |
| | | to groupName: String, |
| | |
| | | struct TagDef: Codable, Equatable { |
| | | var color: Int |
| | | var systemCategoryID: SmartCategoryID? = nil |
| | | var customColor: TagCustomColor? = nil |
| | | } |
| | | |
| | | struct Store: Codable { |
| | |
| | | // MARK: Export / Import |
| | | |
| | | static func exportTo(_ url: URL) throws { |
| | | try ProEntitlementPolicy.requireUnlocked(.layoutExport) |
| | | let store = loadWithEnsuredCategoryScheme() |
| | | let data = try JSONEncoder().encode(store) |
| | | try data.write(to: url, options: .atomic) |
| | | } |
| | | |
| | | static func importFrom(_ url: URL) throws -> Store { |
| | | try ProEntitlementPolicy.requireUnlocked(.layoutImport) |
| | | let previousStore = loadWithEnsuredCategoryScheme() |
| | | let data = try Data(contentsOf: url) |
| | | var store = try JSONDecoder().decode(Store.self, from: data) |
| | |
| | | guard !FileManager.default.fileExists(atPath: storeURL.path) else { return } |
| | | |
| | | let starterCategoryIDs: [SmartCategoryID] = [ |
| | | .uiPrototyping, |
| | | .ide, |
| | | .writing, |
| | | .game, |
| | | .design, |
| | | .development, |
| | | .office, |
| | | .entertainment, |
| | | .system, |
| | | .systemEnhancement, |
| | | .gtd |
| | | ] |
| | | |
| | |
| | | |
| | | /// Persist the visible app order for one stable container. |
| | | static func reorderApps(inContainer containerID: String, orderedPaths: [String]) { |
| | | guard ProEntitlementPolicy.isUnlocked(.persistentAppSorting) else { return } |
| | | |
| | | var store = TagDatabase.load() |
| | | _ = TagDatabase.normalizeContainerAppOrder(in: &store) |
| | | let previousStore = store |
| | |
| | | TagDatabase.save(store) |
| | | } |
| | | |
| | | static func setAppNote(_ note: String, for path: String) { |
| | | @discardableResult |
| | | static func setAppNote(_ note: String, for path: String) -> ProNoteSaveDecision { |
| | | var store = TagDatabase.load() |
| | | let previousStore = store |
| | | let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | let limited = String(trimmed.prefix(TagDatabase.maxAppNoteLength)) |
| | | let decision = ProEntitlementPolicy.noteSaveDecision( |
| | | note: limited, |
| | | for: path, |
| | | in: store |
| | | ) |
| | | |
| | | guard case .allow = decision else { |
| | | return decision |
| | | } |
| | | |
| | | let previousStore = store |
| | | if limited.isEmpty { |
| | | store.appNotes.removeValue(forKey: path) |
| | | store.appNoteMetadata[path] = TagDatabase.AppNoteMetadata( |
| | |
| | | previous: previousStore, |
| | | reason: "edit-app-note" |
| | | ) |
| | | return decision |
| | | } |
| | | |
| | | static func moveApp(path: String, from sourceTag: String, to targetTag: String, color: Int, copy: Bool) { |
| | |
| | | let previousStore = store |
| | | var tagDef = store.tags[tag] ?? TagDatabase.TagDef(color: color, systemCategoryID: nil) |
| | | tagDef.color = color |
| | | tagDef.customColor = nil |
| | | store.tags[tag] = tagDef |
| | | TagDatabase.saveUserCategorySchemeMutation( |
| | | store, |
| | |
| | | reason: "set-tag-color" |
| | | ) |
| | | } |
| | | |
| | | /// Set a Pro custom color for a tag without changing its fallback base color. |
| | | @discardableResult |
| | | static func setCustomColor(_ customColor: TagCustomColor, for tag: String) -> Bool { |
| | | guard ProEntitlementPolicy.isUnlocked(.customTagColors) else { |
| | | return false |
| | | } |
| | | var store = TagDatabase.load() |
| | | let previousStore = store |
| | | var tagDef = store.tags[tag] ?? TagDatabase.TagDef(color: 0, systemCategoryID: nil) |
| | | tagDef.customColor = customColor |
| | | store.tags[tag] = tagDef |
| | | TagDatabase.saveUserCategorySchemeMutation( |
| | | store, |
| | | previous: previousStore, |
| | | reason: "set-tag-custom-color" |
| | | ) |
| | | return true |
| | | } |
| | | } |