diff --git a/Pika.xcodeproj/project.pbxproj b/Pika.xcodeproj/project.pbxproj index 22188579..88ae5072 100644 --- a/Pika.xcodeproj/project.pbxproj +++ b/Pika.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 1C7412E01A6EF6E6D516CEB6 /* ColorHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB487C20A8FA16610A2DE6C4 /* ColorHistory.swift */; }; 220D5E9428DB154300B6285E /* AppModeButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9328DB154300B6285E /* AppModeButtons.swift */; }; 220D5E9828DB158400B6285E /* AppModeToggleGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */; }; 221600F925A62E5B00B8B7D9 /* IconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 221600F825A62E5B00B8B7D9 /* IconImage.swift */; }; @@ -16,8 +17,22 @@ 22D28DB72862377F00FC7DD4 /* OverflowContentViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22D28DB62862377F00FC7DD4 /* OverflowContentViewModifier.swift */; }; 22EF1D9B25B7AA18001102FA /* Sequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22EF1D9A25B7AA18001102FA /* Sequence.swift */; }; 22FE80B325BA0F820063759E /* KeyboardShortcutItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22FE80B225BA0F820063759E /* KeyboardShortcutItem.swift */; }; + 3FB457874C3F741FC84DADD6 /* ColorHistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1804CBF1E38631A06B89C401 /* ColorHistoryManager.swift */; }; + 68267E79E2A1817195C7C16F /* ColorHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB487C20A8FA16610A2DE6C4 /* ColorHistory.swift */; }; C49A11482DB394F500EE7E80 /* APCACompliance.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49A11472DB394F500EE7E80 /* APCACompliance.swift */; }; C49A11492DB394F500EE7E80 /* APCACompliance.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49A11472DB394F500EE7E80 /* APCACompliance.swift */; }; + B1C2D3E4F5061728394A5B6C /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* ColorPalette.swift */; }; + C1D2E3F4051627384A5B6C7D /* ColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5C /* ColorPalette.swift */; }; + B1C2D3E4F5061728394A5B6D /* PaletteSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* PaletteSyncManager.swift */; }; + C1D2E3F4051627384A5B6C7E /* PaletteSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5D /* PaletteSyncManager.swift */; }; + B1C2D3E4F5061728394A5B6E /* ColorPaletteBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5E /* ColorPaletteBar.swift */; }; + C1D2E3F4051627384A5B6C7F /* ColorPaletteBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5E /* ColorPaletteBar.swift */; }; + B1C2D3E4F5061728394A5B6F /* ColorPalettes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5F /* ColorPalettes.swift */; }; + C1D2E3F4051627384A5B6C80 /* ColorPalettes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5F /* ColorPalettes.swift */; }; + B1C2D3E4F5061728394A5B70 /* PaletteEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B60 /* PaletteEditor.swift */; }; + C1D2E3F4051627384A5B6C81 /* PaletteEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B60 /* PaletteEditor.swift */; }; + D1E2F3A4B5C6D7E8F9A0B1C1 /* SwatchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6D7E8F9A0B1C0 /* SwatchBar.swift */; }; + D1E2F3A4B5C6D7E8F9A0B1C2 /* SwatchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1E2F3A4B5C6D7E8F9A0B1C0 /* SwatchBar.swift */; }; EA0C525025AA729300AFF716 /* Visualisation.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C524F25AA729300AFF716 /* Visualisation.swift */; }; EA0C526025AB5A2B00AFF716 /* NavigationMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */; }; EA0C526425AB5D1700AFF716 /* PikaWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C526325AB5D1700AFF716 /* PikaWindow.swift */; }; @@ -128,9 +143,11 @@ F8ABAC5B2EAAD0DF008CD152 /* ColorPickOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8ABAC592EAAD0DF008CD152 /* ColorPickOverlay.swift */; }; F8ABAC5D2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */; }; F8ABAC5E2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */; }; + FC6B02943A97C369AEDA0D64 /* ColorHistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1804CBF1E38631A06B89C401 /* ColorHistoryManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 1804CBF1E38631A06B89C401 /* ColorHistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorHistoryManager.swift; sourceTree = ""; }; 220D5E9328DB154300B6285E /* AppModeButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppModeButtons.swift; sourceTree = ""; }; 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppModeToggleGroup.swift; sourceTree = ""; }; 221600F825A62E5B00B8B7D9 /* IconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconImage.swift; sourceTree = ""; }; @@ -152,6 +169,13 @@ C1BF64202C1AE53C004D33DD /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; C1BF64212C1AE53C004D33DD /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Main.strings"; sourceTree = ""; }; C49A11472DB394F500EE7E80 /* APCACompliance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APCACompliance.swift; sourceTree = ""; }; + CB487C20A8FA16610A2DE6C4 /* ColorHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorHistory.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5C /* ColorPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalette.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5D /* PaletteSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteSyncManager.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5E /* ColorPaletteBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteBar.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B5F /* ColorPalettes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPalettes.swift; sourceTree = ""; }; + A1B2C3D4E5F60718293A4B60 /* PaletteEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteEditor.swift; sourceTree = ""; }; + D1E2F3A4B5C6D7E8F9A0B1C0 /* SwatchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwatchBar.swift; sourceTree = ""; }; EA0C524F25AA729300AFF716 /* Visualisation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Visualisation.swift; sourceTree = ""; }; EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationMenuItems.swift; sourceTree = ""; }; EA0C526325AB5D1700AFF716 /* PikaWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PikaWindow.swift; sourceTree = ""; }; @@ -306,6 +330,7 @@ EAD0B6D7259CED1D00FA2F67 /* Info.plist */, EAE2EB9B2D03697600FA9BC9 /* Info (Mac App Store).plist */, EA72BB8125A5334B008205E7 /* Metal */, + D1E2F30415263748596A7B8C /* Models */, EAD0B6D8259CED1D00FA2F67 /* Pika.entitlements */, EAD0B6D1259CED1D00FA2F67 /* Preview Content */, EAD0B71A259D14C200FA2F67 /* Styles */, @@ -344,10 +369,12 @@ isa = PBXGroup; children = ( EA7B199B25FBA0E600E06D9D /* ClosestVector.swift */, + 1804CBF1E38631A06B89C401 /* ColorHistoryManager.swift */, F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */, EACA8A44260501210064035C /* Exporter.swift */, EAD0B6F1259CF29300FA2F67 /* Eyedroppers.swift */, EA7B199525FBA08100E06D9D /* LoadColors.swift */, + A1B2C3D4E5F60718293A4B5D /* PaletteSyncManager.swift */, EA0C526325AB5D1700AFF716 /* PikaWindow.swift */, 22D28DB62862377F00FC7DD4 /* OverflowContentViewModifier.swift */, ); @@ -362,6 +389,9 @@ 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */, EAD0B6F5259CF29300FA2F67 /* AppVersion.swift */, 22903B0228294F49004BB9F0 /* ColorExampleRow.swift */, + CB487C20A8FA16610A2DE6C4 /* ColorHistory.swift */, + A1B2C3D4E5F60718293A4B5E /* ColorPaletteBar.swift */, + A1B2C3D4E5F60718293A4B5F /* ColorPalettes.swift */, EA635DE025B4FC580014D91A /* ColorPickers.swift */, F8ABAC592EAAD0DF008CD152 /* ColorPickOverlay.swift */, EABAEADF284D50D1000716AE /* ComplianceButtons.swift */, @@ -377,8 +407,10 @@ EAA8AE1825B8EC070049299B /* KeyboardShortcutKey.swift */, EAD0B71B259D151400FA2F67 /* NavigationMenu.swift */, EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */, + A1B2C3D4E5F60718293A4B60 /* PaletteEditor.swift */, EAD0B6F4259CF29300FA2F67 /* PreferencesView.swift */, EA72BB8725A53750008205E7 /* SplashView.swift */, + D1E2F3A4B5C6D7E8F9A0B1C0 /* SwatchBar.swift */, EA635DF025B5A6D80014D91A /* Toast.swift */, EAF100CC25C785C4006E1EC3 /* TouchBarVisual.swift */, 226FD60F25A940F90021A67F /* VisualEffect.swift */, @@ -387,6 +419,14 @@ path = Views; sourceTree = ""; }; + D1E2F30415263748596A7B8C /* Models */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F60718293A4B5C /* ColorPalette.swift */, + ); + path = Models; + sourceTree = ""; + }; EAD0B71A259D14C200FA2F67 /* Styles */ = { isa = PBXGroup; children = ( @@ -657,6 +697,14 @@ EAD0B713259CFD2000FA2F67 /* Defaults.swift in Sources */, 226FD61025A940F90021A67F /* VisualEffect.swift in Sources */, EAD0B6FE259CF29C00FA2F67 /* Constants.swift in Sources */, + FC6B02943A97C369AEDA0D64 /* ColorHistoryManager.swift in Sources */, + 68267E79E2A1817195C7C16F /* ColorHistory.swift in Sources */, + D1E2F3A4B5C6D7E8F9A0B1C1 /* SwatchBar.swift in Sources */, + B1C2D3E4F5061728394A5B6C /* ColorPalette.swift in Sources */, + B1C2D3E4F5061728394A5B6D /* PaletteSyncManager.swift in Sources */, + B1C2D3E4F5061728394A5B6E /* ColorPaletteBar.swift in Sources */, + B1C2D3E4F5061728394A5B6F /* ColorPalettes.swift in Sources */, + B1C2D3E4F5061728394A5B70 /* PaletteEditor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -717,6 +765,14 @@ EAE23DD52D032A38005BB270 /* Defaults.swift in Sources */, EAE23DD62D032A38005BB270 /* VisualEffect.swift in Sources */, EAE23DD72D032A38005BB270 /* Constants.swift in Sources */, + 3FB457874C3F741FC84DADD6 /* ColorHistoryManager.swift in Sources */, + 1C7412E01A6EF6E6D516CEB6 /* ColorHistory.swift in Sources */, + D1E2F3A4B5C6D7E8F9A0B1C2 /* SwatchBar.swift in Sources */, + C1D2E3F4051627384A5B6C7D /* ColorPalette.swift in Sources */, + C1D2E3F4051627384A5B6C7E /* PaletteSyncManager.swift in Sources */, + C1D2E3F4051627384A5B6C7F /* ColorPaletteBar.swift in Sources */, + C1D2E3F4051627384A5B6C80 /* ColorPalettes.swift in Sources */, + C1D2E3F4051627384A5B6C81 /* PaletteEditor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Pika/AppDelegate.swift b/Pika/AppDelegate.swift index 8fbc058a..78a56dd2 100644 --- a/Pika/AppDelegate.swift +++ b/Pika/AppDelegate.swift @@ -18,8 +18,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { var aboutWindow: NSWindow! var preferencesWindow: NSWindow! var eyedroppers: Eyedroppers! + let colorHistoryManager = ColorHistoryManager() + /// Retained for the app's lifetime to keep the iCloud KVS observer alive. + let paletteSyncManager = PaletteSyncManager() var undoManager = UndoManager() + private var cachedPaletteCount = 0 + private var hadColorHistory = false + + override init() { + super.init() + eyedroppers = Eyedroppers() + eyedroppers.foreground.undoManager = undoManager + eyedroppers.background.undoManager = undoManager + eyedroppers.foreground.colorHistoryManager = colorHistoryManager + eyedroppers.background.colorHistoryManager = colorHistoryManager + } var pikaTouchBarController: PikaTouchBarController! var splashTouchBarController: SplashTouchBarController! @@ -27,6 +41,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let notificationCenter = NotificationCenter.default + private func idealWindowContentHeight() -> CGFloat { + SwatchLayout.totalHeight( + base: 230, + hasHistory: hadColorHistory, + paletteCount: cachedPaletteCount + ) + } + + func updateWindowSize(animate: Bool) { + guard pikaWindow != nil else { return } + let contentHeight = idealWindowContentHeight() + let targetContent = NSRect(x: 0, y: 0, width: CGFloat(pikaWindow.frame.width), height: contentHeight) + let targetFrame = pikaWindow.frameRect(forContentRect: targetContent) + // Pin the top edge of the window. + let currentFrame = pikaWindow.frame + let newFrame = NSRect( + x: currentFrame.origin.x, + y: currentFrame.origin.y + currentFrame.size.height - targetFrame.height, + width: currentFrame.size.width, + height: targetFrame.height + ) + pikaWindow.setFrame(newFrame, display: true, animate: animate && pikaWindow.isVisible) + } + func setupAppMode() { var currentMode = Defaults[.appMode] == .regular ? NSApplication.ActivationPolicy.regular @@ -41,7 +79,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { NSApp.setActivationPolicy(newMode) NSApp.activate(ignoringOtherApps: true) if change.newValue == .regular { - DispatchQueue.main.asyncAfter(deadline: .now()) { + DispatchQueue.main.async { NSApp.unhide(self) if let window = NSApp.windows.first { @@ -99,11 +137,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { setupAppMode() setupStatusBar() - // Set up eyedroppers - eyedroppers = Eyedroppers() - eyedroppers.foreground.undoManager = undoManager - eyedroppers.background.undoManager = undoManager - // Define content view let contentView = ContentView() .environmentObject(eyedroppers) @@ -112,13 +145,47 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { maxWidth: 650, minHeight: 230, idealHeight: 230, - maxHeight: 400, + maxHeight: 550, alignment: .center) pikaWindow = PikaWindow.createPrimaryWindow() pikaWindow.contentView = NSHostingView(rootView: contentView) pikaTouchBarController = PikaTouchBarController(window: pikaWindow) + cachedPaletteCount = PaletteParser.countSections(Defaults[.paletteText]) + hadColorHistory = Defaults[.showColorHistory] && !Defaults[.colorHistory].isEmpty + updateWindowSize(animate: false) + + Defaults.observe(.paletteText) { [weak self] change in + DispatchQueue.main.async { + guard let self = self else { return } + let newCount = PaletteParser.countSections(change.newValue) + guard newCount != self.cachedPaletteCount else { return } + self.cachedPaletteCount = newCount + self.updateWindowSize(animate: true) + } + }.tieToLifetime(of: self) + + Defaults.observe(.colorHistory) { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + let hasHistory = Defaults[.showColorHistory] && !Defaults[.colorHistory].isEmpty + guard hasHistory != self.hadColorHistory else { return } + self.hadColorHistory = hasHistory + self.updateWindowSize(animate: true) + } + }.tieToLifetime(of: self) + + Defaults.observe(.showColorHistory) { [weak self] _ in + DispatchQueue.main.async { + guard let self = self else { return } + let hasHistory = Defaults[.showColorHistory] && !Defaults[.colorHistory].isEmpty + guard hasHistory != self.hadColorHistory else { return } + self.hadColorHistory = hasHistory + self.updateWindowSize(animate: true) + } + }.tieToLifetime(of: self) + // Define global keyboard shortcuts KeyboardShortcuts.onKeyUp(for: .togglePika) { [] in if Defaults[.viewedSplash] { @@ -157,25 +224,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // swiftlint:disable function_body_length @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent _: NSAppleEventDescriptor) { if let urlString = event.forKeyword(AEKeyword(keyDirectObject))?.stringValue { - let url = URL(string: urlString) - guard url != nil, let scheme = url!.scheme, let action = url!.host else { - // some error + guard let url = URL(string: urlString), + let scheme = url.scheme, + let action = url.host + else { return } - var list = url!.pathComponents.dropFirst() + var list = url.pathComponents.dropFirst() let task = list.popFirst() let colorFormat = list.popFirst() if scheme.caseInsensitiveCompare("pika") == .orderedSame { - if colorFormat != nil { - if let format = ColorFormat.withLabel(colorFormat!) { - Defaults[.colorFormat] = format - } + if let colorFormat = colorFormat, + let format = ColorFormat.withLabel(colorFormat) + { + Defaults[.colorFormat] = format } if action == "format" { - if let format = ColorFormat.withLabel(task!) { + if let task = task, let format = ColorFormat.withLabel(task) { Defaults[.colorFormat] = format } } @@ -385,14 +453,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } @IBAction func triggerSystemPickerForeground(_: Any) { + eyedroppers.foreground.togglePicker() notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerSystemPickerForeground), object: self) } @IBAction func triggerSystemPickerBackground(_: Any) { + eyedroppers.background.togglePicker() notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerSystemPickerBackground), object: self) } @IBAction func triggerSwap(_: Any) { + swap(&eyedroppers.foreground.color, &eyedroppers.background.color) notificationCenter.post(name: Notification.Name(PikaConstants.ncTriggerSwap), object: self) } diff --git a/Pika/Assets/de.lproj/Localizable.strings b/Pika/Assets/de.lproj/Localizable.strings index 1eb8f0e9..2e9f801a 100644 --- a/Pika/Assets/de.lproj/Localizable.strings +++ b/Pika/Assets/de.lproj/Localizable.strings @@ -351,3 +351,6 @@ /* Duration: */ "preferences.overlay.duration" = "Dauer"; + +/* Color History */ +"color.history" = "Color History"; diff --git a/Pika/Assets/en.lproj/Localizable.strings b/Pika/Assets/en.lproj/Localizable.strings index 22611232..01a050f5 100644 --- a/Pika/Assets/en.lproj/Localizable.strings +++ b/Pika/Assets/en.lproj/Localizable.strings @@ -351,3 +351,15 @@ /* Duration: */ "preferences.overlay.duration" = "Duration"; + +/* Color History */ +"color.history" = "Color History"; + +/* Show color history */ +"preferences.history.show" = "Show color history"; + +/* Custom Color Palettes */ +"palette.title" = "Custom Color Palettes"; + +/* Palette description */ +"palette.description" = "Define color palettes for quick access to your project colors"; diff --git a/Pika/Assets/es.lproj/Localizable.strings b/Pika/Assets/es.lproj/Localizable.strings index d129c5c0..deaf9ddd 100644 --- a/Pika/Assets/es.lproj/Localizable.strings +++ b/Pika/Assets/es.lproj/Localizable.strings @@ -351,3 +351,6 @@ /* Duration: */ "preferences.overlay.duration" = "Duración"; + +/* Color History */ +"color.history" = "Color History"; diff --git a/Pika/Assets/fr.lproj/Localizable.strings b/Pika/Assets/fr.lproj/Localizable.strings index 8a5f265f..b4d8a8ec 100644 --- a/Pika/Assets/fr.lproj/Localizable.strings +++ b/Pika/Assets/fr.lproj/Localizable.strings @@ -351,3 +351,6 @@ /* Duration: */ "preferences.overlay.duration" = "Durée"; + +/* Color History */ +"color.history" = "Color History"; diff --git a/Pika/Assets/hr.lproj/Localizable.strings b/Pika/Assets/hr.lproj/Localizable.strings index fe670cfd..724abbb4 100644 --- a/Pika/Assets/hr.lproj/Localizable.strings +++ b/Pika/Assets/hr.lproj/Localizable.strings @@ -351,3 +351,6 @@ /* Duration: */ "preferences.overlay.duration" = "Trajanje"; + +/* Color History */ +"color.history" = "Color History"; diff --git a/Pika/Assets/pl.lproj/Localizable.strings b/Pika/Assets/pl.lproj/Localizable.strings index a31effa0..231c2c04 100644 --- a/Pika/Assets/pl.lproj/Localizable.strings +++ b/Pika/Assets/pl.lproj/Localizable.strings @@ -351,3 +351,6 @@ /* Duration: */ "preferences.overlay.duration" = "Czas trwania"; + +/* Color History */ +"color.history" = "Color History"; diff --git a/Pika/Assets/zh-Hans.lproj/Localizable.strings b/Pika/Assets/zh-Hans.lproj/Localizable.strings index 28915b3e..47e45a41 100644 Binary files a/Pika/Assets/zh-Hans.lproj/Localizable.strings and b/Pika/Assets/zh-Hans.lproj/Localizable.strings differ diff --git a/Pika/Assets/zh-Hant.lproj/Localizable.strings b/Pika/Assets/zh-Hant.lproj/Localizable.strings index 073cefe7..a18f5dd5 100644 Binary files a/Pika/Assets/zh-Hant.lproj/Localizable.strings and b/Pika/Assets/zh-Hant.lproj/Localizable.strings differ diff --git a/Pika/Constants/Constants.swift b/Pika/Constants/Constants.swift index 333d4f9a..156fea7a 100644 --- a/Pika/Constants/Constants.swift +++ b/Pika/Constants/Constants.swift @@ -89,6 +89,7 @@ enum PikaText { static let textColorCopy = NSLocalizedString("color.copy", comment: "Copy") static let textColorSystemPicker = NSLocalizedString("color.system", comment: "System picker") static let textColorCopied = NSLocalizedString("color.copy.toast", comment: "Copied") + static let textColorHistory = NSLocalizedString("color.history", comment: "Color History") /* * Menu @@ -274,12 +275,24 @@ enum PikaText { comment: "Set a global hotkey shortcut to invoke Pika" ) + // Color History + static let textShowColorHistory = NSLocalizedString( + "preferences.history.show", + comment: "Show color history" + ) + // Color Overlay static let textShowColorOverlay = NSLocalizedString( "preferences.overlay.show", comment: "Show color overlay after picking" ) static let textDuration = NSLocalizedString("preferences.overlay.duration", comment: "Duration:") + + // Color Palettes + static let textPalettesTitle = NSLocalizedString("palette.title", comment: "Custom Color Palettes") + static let textPalettesDescription = NSLocalizedString( + "palette.description", comment: "Palette description" + ) } // swiftlint:enable trailing_comma diff --git a/Pika/Constants/Defaults.swift b/Pika/Constants/Defaults.swift index 5c6a8c76..377edc10 100644 --- a/Pika/Constants/Defaults.swift +++ b/Pika/Constants/Defaults.swift @@ -66,4 +66,7 @@ extension Defaults.Keys { static let contrastStandard = Key("contrastStandard", default: .wcag) static let showColorOverlay = Key("showColorOverlay", default: true) static let colorOverlayDuration = Key("colorOverlayDuration", default: 2.0) + static let showColorHistory = Key("showColorHistory", default: true) + static let colorHistory = Key<[String]>("colorHistory", default: []) + static let paletteText = Key("paletteText", default: "") } diff --git a/Pika/Extensions/Cula.swift b/Pika/Extensions/Cula.swift index db87d27d..0f7cc6b7 100644 --- a/Pika/Extensions/Cula.swift +++ b/Pika/Extensions/Cula.swift @@ -493,6 +493,223 @@ extension NSColor { func getUIColor() -> (NSColor) { luminance < 0.5 ? NSColor.white : NSColor.black } + + // MARK: - Multi-format color string parsing + + /// Creates an NSColor from a color string in any supported format: + /// hex (`#RGB`, `#RRGGBB`), `rgb()`, `hsl()`, `hsb()`, `lab()`, `oklch()`, `rgba()`. + static func fromColorString(_ string: String) -> NSColor? { + let trimmed = string.trimmingCharacters(in: .whitespaces) + let lower = trimmed.lowercased() + + if lower.hasPrefix("oklch(") { return parseOKLCHString(trimmed) } + if lower.hasPrefix("rgb(") { return parseRGBString(trimmed) } + if lower.hasPrefix("hsl(") { return parseHSLString(trimmed) } + if lower.hasPrefix("hsb(") { return parseHSBString(trimmed) } + if lower.hasPrefix("lab(") { return parseLABString(trimmed) } + if lower.hasPrefix("rgba(") { return parseOpenGLString(trimmed) } + return parseHexString(trimmed) + } + + // MARK: Extraction helpers + + /// Extracts the content between the first `(` and last `)` in a function-style color string. + private static func extractFunctionContent(_ string: String) -> String? { + guard let open = string.firstIndex(of: "("), + let close = string.lastIndex(of: ")"), + close > open + else { return nil } + return String(string[string.index(after: open) ..< close]) + } + + /// Parses space- or comma-separated numeric values, tracking which had a `%` suffix. + private static func extractValues(from content: String) -> [(value: CGFloat, isPercentage: Bool)] { + let tokens = content + .components(separatedBy: CharacterSet(charactersIn: ", ")) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + return tokens.compactMap { token in + let isPct = token.hasSuffix("%") + let numStr = isPct ? String(token.dropLast()) : token + guard let value = Double(numStr) else { return nil } + return (value: CGFloat(value), isPercentage: isPct) + } + } + + // MARK: Individual format parsers + + private static func parseHexString(_ string: String) -> NSColor? { + var hex = string.trimmingCharacters(in: .whitespaces) + if hex.hasPrefix("#") { hex = String(hex.dropFirst()) } + guard hex.allSatisfy(\.isHexDigit) else { return nil } + if hex.count == 3 { hex = hex.map { "\($0)\($0)" }.joined() } + guard hex.count == 6 else { return nil } + return NSColor(hex: hex) + } + + /// Parses `rgb(R, G, B)` where R/G/B are 0–255 integers. + private static func parseRGBString(_ string: String) -> NSColor? { + guard let content = extractFunctionContent(string) else { return nil } + let vals = extractValues(from: content) + guard vals.count == 3 else { return nil } + let r = vals[0].value / 255.0 + let g = vals[1].value / 255.0 + let b = vals[2].value / 255.0 + return NSColor(red: r, green: g, blue: b, alpha: 1.0) + } + + /// Parses a three-component `func(H, S/S%, V/V%)` color string where + /// the first value is hue (0–360) and the second/third may be percentages. + private static func parseHSxString( + _ string: String, + make: (CGFloat, CGFloat, CGFloat) -> NSColor + ) -> NSColor? { + guard let content = extractFunctionContent(string) else { return nil } + let vals = extractValues(from: content) + guard vals.count == 3 else { return nil } + let h = vals[0].value / 360.0 + let s = vals[1].isPercentage ? vals[1].value / 100.0 : vals[1].value + let v = vals[2].isPercentage ? vals[2].value / 100.0 : vals[2].value + return make(h, s, v) + } + + /// Parses `hsl(H, S%, L%)` where H is 0–360, S and L are 0–100. + private static func parseHSLString(_ string: String) -> NSColor? { + parseHSxString(string) { h, s, l in colorFromHSL(h: h, s: s, l: l) } + } + + /// Parses `hsb(H, S%, B%)` where H is 0–360, S and B are 0–100. + private static func parseHSBString(_ string: String) -> NSColor? { + parseHSxString(string) { h, s, b in NSColor(hue: h, saturation: s, brightness: b, alpha: 1.0) } + } + + /// Parses `lab(L A B)` (CSS Color Level 4) where L is 0–100, A and B are unbounded. + private static func parseLABString(_ string: String) -> NSColor? { + guard let content = extractFunctionContent(string) else { return nil } + let vals = extractValues(from: content) + guard vals.count == 3 else { return nil } + let l = vals[0].value + let a = vals[1].value + let b = vals[2].value + return colorFromLAB(l: l, a: a, b: b) + } + + /// Parses `oklch(L% C H)` where L is 0–100% (or 0–1), C is chroma, H is hue in degrees. + private static func parseOKLCHString(_ string: String) -> NSColor? { + guard let content = extractFunctionContent(string) else { return nil } + let vals = extractValues(from: content) + guard vals.count == 3 else { return nil } + let l: CGFloat + if vals[0].isPercentage { + l = vals[0].value / 100.0 + } else { + // Heuristic: values > 1 are likely percentages written without % + l = vals[0].value > 1.0 ? vals[0].value / 100.0 : vals[0].value + } + let c = vals[1].value + let h = vals[2].value + return colorFromOKLCH(l: l, c: c, h: h) + } + + /// Parses `rgba(R, G, B, A)` (OpenGL) where all components are 0.0–1.0. + private static func parseOpenGLString(_ string: String) -> NSColor? { + guard let content = extractFunctionContent(string) else { return nil } + let vals = extractValues(from: content) + guard vals.count == 4 else { return nil } + return NSColor(red: vals[0].value, green: vals[1].value, blue: vals[2].value, alpha: vals[3].value) + } + + // MARK: Reverse color conversions + + /// HSL → NSColor. All inputs are 0–1. + private static func colorFromHSL(h: CGFloat, s: CGFloat, l: CGFloat) -> NSColor { + guard s > 0 else { + return NSColor(red: l, green: l, blue: l, alpha: 1.0) + } + + func hueToRGB(_ p: CGFloat, _ q: CGFloat, _ t: CGFloat) -> CGFloat { + var t = t + if t < 0 { t += 1 } + if t > 1 { t -= 1 } + if t < 1.0 / 6.0 { return p + (q - p) * 6.0 * t } + if t < 1.0 / 2.0 { return q } + if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6.0 } + return p + } + + let q = l < 0.5 ? l * (1.0 + s) : l + s - l * s + let p = 2.0 * l - q + let r = hueToRGB(p, q, h + 1.0 / 3.0) + let g = hueToRGB(p, q, h) + let b = hueToRGB(p, q, h - 1.0 / 3.0) + return NSColor(red: r, green: g, blue: b, alpha: 1.0) + } + + /// CIE-LAB → NSColor via XYZ → linear RGB → sRGB. + private static func colorFromLAB(l: CGFloat, a: CGFloat, b: CGFloat) -> NSColor { + let fy = (l + 16.0) / 116.0 + let fx = a / 500.0 + fy + let fz = fy - b / 200.0 + + let delta: CGFloat = 6.0 / 29.0 + func fInverse(_ t: CGFloat) -> CGFloat { + t > delta ? pow(t, 3.0) : 3.0 * pow(delta, 2.0) * (t - 4.0 / 29.0) + } + + // D65 reference white + let x = 0.95047 * fInverse(fx) + let y = 1.00000 * fInverse(fy) + let z = 1.08883 * fInverse(fz) + + // XYZ → linear RGB (sRGB matrix inverse) + let rLin = x * 3.2404542 + y * -1.5371385 + z * -0.4985314 + let gLin = x * -0.9692660 + y * 1.8760108 + z * 0.0415560 + let bLin = x * 0.0556434 + y * -0.2040259 + z * 1.0572252 + + return NSColor( + colorSpace: .sRGB, + components: [srgbGamma(rLin), srgbGamma(gLin), srgbGamma(bLin), 1.0], + count: 4 + ) + } + + /// OKLCH → NSColor via Oklab → LMS → linear RGB → sRGB. + private static func colorFromOKLCH(l: CGFloat, c: CGFloat, h: CGFloat) -> NSColor { + // Polar → Cartesian + let hRad = h * .pi / 180.0 + let a = c * cos(hRad) + let b = c * sin(hRad) + + // Oklab → LMS (cube-root domain) + let l_ = l + 0.3963377774 * a + 0.2158037573 * b + let m_ = l - 0.1055613458 * a - 0.0638541728 * b + let s_ = l - 0.0894841775 * a - 1.2914855480 * b + + // Undo cube root + let lCubed = l_ * l_ * l_ + let mCubed = m_ * m_ * m_ + let sCubed = s_ * s_ * s_ + + // LMS → linear RGB + let rLin = 4.0767416621 * lCubed - 3.3077115913 * mCubed + 0.2309699292 * sCubed + let gLin = -1.2684380046 * lCubed + 2.6097574011 * mCubed - 0.3413193965 * sCubed + let bLin = -0.0041960863 * lCubed - 0.7034186147 * mCubed + 1.7076147010 * sCubed + + return NSColor( + colorSpace: .sRGB, + components: [srgbGamma(rLin), srgbGamma(gLin), srgbGamma(bLin), 1.0], + count: 4 + ) + } + + /// Applies sRGB gamma encoding and clamps to 0–1. + private static func srgbGamma(_ linear: CGFloat) -> CGFloat { + let encoded = linear <= 0.0031308 + ? 12.92 * linear + : 1.055 * pow(linear, 1.0 / 2.4) - 0.055 + return max(0, min(1, encoded)) + } } // swiftlint:enable large_tuple diff --git a/Pika/Models/ColorPalette.swift b/Pika/Models/ColorPalette.swift new file mode 100644 index 00000000..6067c3f5 --- /dev/null +++ b/Pika/Models/ColorPalette.swift @@ -0,0 +1,203 @@ +import Cocoa +import Foundation + +struct PaletteColor: Equatable { + /// Full-precision color for rendering and format conversion. + let color: NSColor + /// Hex string used as an identifier (e.g. for color history lookups). + let hex: String + let name: String? + + static func == (lhs: PaletteColor, rhs: PaletteColor) -> Bool { + lhs.hex == rhs.hex && lhs.name == rhs.name + } +} + +struct ColorPalette: Identifiable, Equatable { + let id: String + let name: String + let colors: [PaletteColor] +} + +/// Parses palette text format: `[Name]` header followed by comma-separated colors +/// with optional `(label)` names. Supports all color formats: hex, rgb(), hsl(), +/// hsb(), lab(), oklch(), rgba(). Example: `#FF6B35(Tangerine), oklch(80% 0.15 90)` +enum PaletteParser { + static let maxColorsPerPalette = 20 + static let maxPalettes = 5 + /// Splits a color line on commas that are outside parentheses, so that + /// values like `rgb(255, 0, 0)` are kept intact. + static func splitColorEntries(_ line: String) -> [String] { + var entries: [String] = [] + var current = "" + var parenDepth = 0 + + for char in line { + switch char { + case "(": + parenDepth += 1 + current.append(char) + case ")": + parenDepth = max(0, parenDepth - 1) + current.append(char) + case "," where parenDepth == 0: + entries.append(current) + current = "" + default: + current.append(char) + } + } + if !current.trimmingCharacters(in: .whitespaces).isEmpty { + entries.append(current) + } + return entries + } + + /// Extracts an optional trailing `(label)` name from a color entry. + /// For function-style colors like `rgb(255, 0, 0)(Red)`, identifies the + /// label as the parenthesized group that follows the function's closing paren. + /// Distinguishes labels from function arguments by checking whether what + /// precedes the trailing `(...)` is itself a valid color string. + /// Returns the color string, an optional label name, and the already-parsed + /// NSColor when a label was detected (to avoid double-parsing). + private static func extractNameAndColorString( + _ entry: String + ) -> (colorString: String, name: String?, validatedColor: NSColor?) { + let trimmed = entry.trimmingCharacters(in: .whitespaces) + + // Walk backward: if the entry ends with `)`, check whether it's a trailing label. + guard trimmed.hasSuffix(")") else { + return (trimmed, nil, nil) + } + + // Find the matching `(` for the final `)`. + var depth = 0 + var labelOpenIndex: String.Index? + for index in trimmed.indices.reversed() { + if trimmed[index] == ")" { depth += 1 } + else if trimmed[index] == "(" { + depth -= 1 + if depth == 0 { + labelOpenIndex = index + break + } + } + } + + guard let openIdx = labelOpenIndex else { + return (trimmed, nil, nil) + } + + let beforeLabel = trimmed[trimmed.startIndex ..< openIdx] + .trimmingCharacters(in: .whitespaces) + + guard !beforeLabel.isEmpty else { + return (trimmed, nil, nil) + } + + // Only treat the trailing (...) as a name label if what precedes it + // is a valid color. Otherwise the parens are the color function's own + // arguments (e.g. `oklch(...)` with no label). + guard let parsedColor = NSColor.fromColorString(beforeLabel) else { + return (trimmed, nil, nil) + } + + let nameContent = String(trimmed[trimmed.index(after: openIdx) ..< trimmed.index(before: trimmed.endIndex)]) + .trimmingCharacters(in: .whitespaces) + + let name = nameContent.isEmpty ? nil : nameContent + return (beforeLabel, name, parsedColor) + } + + static func parseColorEntry(_ entry: String) -> PaletteColor? { + let (colorString, name, validatedColor) = extractNameAndColorString(entry) + + guard let color = validatedColor ?? NSColor.fromColorString(colorString) else { return nil } + return PaletteColor(color: color, hex: color.toHexString(), name: name) + } + + /// Walks palette text line-by-line, yielding each `[Name]` + colors-line pair. + /// Handler returns `true` to stop early (used by parse() to cap at 5 palettes). + private static func enumerateSections( + _ text: String, + handler: (_ name: String, _ colorsLine: String) -> Bool + ) { + let lines = text.components(separatedBy: .newlines) + var currentName: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("["), trimmed.hasSuffix("]") { + let name = String(trimmed.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespaces) + if !name.isEmpty { + currentName = name + } + continue + } + + if let name = currentName, !trimmed.isEmpty { + currentName = nil + let shouldStop = handler(name, trimmed) + if shouldStop { return } + } + } + } + + /// Counts the number of palette sections without parsing any colors. + /// Used by AppDelegate for window sizing where only the count is needed. + static func countSections(_ text: String) -> Int { + var count = 0 + enumerateSections(text) { _, _ in + count += 1 + return count >= maxPalettes + } + return count + } + + static func parse(_ text: String) -> [ColorPalette] { + var palettes: [ColorPalette] = [] + + enumerateSections(text) { name, colorsLine in + let colors = splitColorEntries(colorsLine) + .compactMap { parseColorEntry($0) } + .prefix(maxColorsPerPalette) + + if !colors.isEmpty { + // Index prefix ensures unique IDs when multiple palettes share a name. + palettes.append(ColorPalette( + id: "\(palettes.count):\(name)", + name: name, + colors: Array(colors) + )) + } + return palettes.count >= maxPalettes + } + + return palettes + } + + static func validate(_ text: String) -> String? { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + var paletteCount = 0 + var maxColorsExceeded = false + + enumerateSections(text) { _, colorsLine in + paletteCount += 1 + if splitColorEntries(colorsLine).count > maxColorsPerPalette { + maxColorsExceeded = true + } + // Stop early once we've seen enough to know both possible violations. + return paletteCount > maxPalettes && maxColorsExceeded + } + + var warnings: [String] = [] + if paletteCount > maxPalettes { + warnings.append("Maximum \(maxPalettes) palettes") + } + if maxColorsExceeded { + warnings.append("Maximum \(maxColorsPerPalette) colors per palette") + } + return warnings.isEmpty ? nil : warnings.joined(separator: ", ") + } +} diff --git a/Pika/Pika.entitlements b/Pika/Pika.entitlements index 852fa1a4..101386c2 100644 --- a/Pika/Pika.entitlements +++ b/Pika/Pika.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) diff --git a/Pika/Utilities/ColorHistoryManager.swift b/Pika/Utilities/ColorHistoryManager.swift new file mode 100644 index 00000000..75bc6a7b --- /dev/null +++ b/Pika/Utilities/ColorHistoryManager.swift @@ -0,0 +1,42 @@ +import Cocoa +import Defaults + +/// Manages a most-recently-used list of picked colors, persisted via Defaults. +/// Two recording modes: immediate (eyedropper picks) and debounced (system color panel, +/// which fires continuously as the user drags). +class ColorHistoryManager { + static let maxEntries = 20 + private var debounceWorkItem: DispatchWorkItem? + + func recordImmediate(_ color: NSColor) { + addColor(color) + } + + /// Coalesces rapid-fire color changes (e.g. dragging in NSColorPanel) into a single history entry. + func recordDebounced(_ color: NSColor) { + debounceWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.addColor(color) + } + debounceWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) + } + + /// Promotes an existing color to the front of the list, or inserts it if new. + /// Called directly by views when tapping a history swatch (without re-recording). + func moveToFront(hex: String) { + var history = Defaults[.colorHistory] + if let index = history.firstIndex(of: hex) { + history.remove(at: index) + } + history.insert(hex, at: 0) + if history.count > Self.maxEntries { + history = Array(history.prefix(Self.maxEntries)) + } + Defaults[.colorHistory] = history + } + + private func addColor(_ color: NSColor) { + moveToFront(hex: color.toHexString()) + } +} diff --git a/Pika/Utilities/Eyedroppers.swift b/Pika/Utilities/Eyedroppers.swift index 88b85efc..a818b41f 100644 --- a/Pika/Utilities/Eyedroppers.swift +++ b/Pika/Utilities/Eyedroppers.swift @@ -8,6 +8,7 @@ class Eyedroppers: ObservableObject { type: .foreground, color: PikaConstants.initialColors.randomElement()! ) @Published var background = Eyedropper(type: .background, color: NSColor.black) + var hasSetInitialBackground = false } class Eyedropper: ObservableObject { @@ -35,17 +36,16 @@ class Eyedropper: ObservableObject { case .background: return #selector(AppDelegate.triggerPickBackground) } } - - var systemPickerSelector: Selector { - switch self { - case .foreground: return #selector(AppDelegate.triggerSystemPickerForeground) - case .background: return #selector(AppDelegate.triggerSystemPickerBackground) - } - } } + /// Tracks which eyedropper currently owns the shared NSColorPanel. + /// Used by togglePicker() to know whether to open or close the panel. + private static var activePickerType: Types? + let type: Types var forceShow = false + /// Injected from AppDelegate; weak to avoid a retain cycle (AppDelegate owns both). + weak var colorHistoryManager: ColorHistoryManager? let colorNames: [ColorName] = loadColors()! var closestVector: ClosestVector! @@ -71,13 +71,29 @@ class Eyedropper: ObservableObject { colorNames[closestVector.compare(color)].name } - func set(_ selectedColor: NSColor) { + /// Pass `recordToHistory: false` when setting from a history/palette tap + /// to avoid re-recording a color the user selected from an existing list. + func set(_ selectedColor: NSColor, recordToHistory: Bool = true) { let previousColor = color undoManager?.registerUndo(withTarget: self) { _ in self.set(previousColor) } color = selectedColor.usingColorSpace(Defaults[.colorSpace])! + if recordToHistory { + colorHistoryManager?.recordImmediate(color) + } + } + + /// Sets a color from a swatch tap (without recording to history) and triggers copy. + func applyFromSwatch(_ color: NSColor) { + set(color, recordToHistory: false) + NSApp.sendAction(type.copySelector, to: nil, from: nil) + } + + /// Promotes an existing color to the front of the history list. + func promoteInHistory(hex: String) { + colorHistoryManager?.moveToFront(hex: hex) } @objc func colorDidChange(sender: AnyObject) { @@ -88,6 +104,7 @@ class Eyedropper: ObservableObject { } color = picker.color.usingColorSpace(Defaults[.colorSpace])! + colorHistoryManager?.recordDebounced(color) } } @@ -100,9 +117,25 @@ class Eyedropper: ObservableObject { panel.color = color panel.mode = .RGB panel.colorSpace = Defaults[.colorSpace] - panel.orderFrontRegardless() panel.setAction(#selector(colorDidChange)) panel.isContinuous = true + Self.activePickerType = type + + // Activate before showing — required in menubar mode where the app + // runs with .accessory activation policy and orderFrontRegardless() + // alone cannot bring the panel on screen from a MenuBarExtra popover. + NSApp.activate(ignoringOtherApps: true) + panel.orderFrontRegardless() + } + + func togglePicker() { + let panel = NSColorPanel.shared + if panel.isVisible, Self.activePickerType == type { + panel.close() + Self.activePickerType = nil + } else { + picker() + } } func start() { @@ -113,13 +146,15 @@ class Eyedropper: ObservableObject { NSApp.sendAction(#selector(AppDelegate.hidePika), to: nil, from: nil) } - DispatchQueue.main.asyncAfter(deadline: .now()) { + DispatchQueue.main.async { let sampler = NSColorSampler() sampler.show { selectedColor in if let selectedColor = selectedColor { if Defaults[.showColorOverlay] { - let colorText = selectedColor.toFormat(format: Defaults[.colorFormat], style: Defaults[.copyFormat]) + let colorText = selectedColor.toFormat( + format: Defaults[.colorFormat], style: Defaults[.copyFormat] + ) let cursorPosition = NSEvent.mouseLocation self.overlayWindow.show( colorText: colorText, diff --git a/Pika/Utilities/PaletteSyncManager.swift b/Pika/Utilities/PaletteSyncManager.swift new file mode 100644 index 00000000..7aff5c74 --- /dev/null +++ b/Pika/Utilities/PaletteSyncManager.swift @@ -0,0 +1,86 @@ +import Cocoa +import Defaults +import Security + +/// Keeps paletteText in sync between local Defaults and iCloud KVS. +/// Degrades gracefully to local-only when the KVS entitlement is absent (e.g. dev builds). +class PaletteSyncManager: ObservableObject { + /// Whether iCloud KVS is available (entitlement present and store initialized). + var iCloudAvailable: Bool { store != nil } + + /// Set when iCloud reports a quota violation. + @Published var quotaExceeded = false + + private var store: NSUbiquitousKeyValueStore? + private let key = Defaults.Keys.paletteText.name + + /// Token to break the cloud→Defaults→cloud feedback loop. + /// Set before writing a cloud value into Defaults; consumed by the Defaults observer + /// so it knows to skip pushing that same value back to iCloud. + /// Only set when the cloud value actually differs from Defaults — otherwise KVO + /// won't fire and the token would go stale, suppressing a future legitimate edit. + private var lastCloudAppliedValue: String? + + private static var hasKVSEntitlement: Bool { + guard let task = SecTaskCreateFromSelf(nil) else { return false } + let value = SecTaskCopyValueForEntitlement( + task, "com.apple.developer.ubiquity-kvstore-identifier" as CFString, nil + ) + return value != nil + } + + init() { + if Self.hasKVSEntitlement { + let kvStore = NSUbiquitousKeyValueStore.default + store = kvStore + + NotificationCenter.default.addObserver( + self, + selector: #selector(cloudDidChange(_:)), + name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: kvStore + ) + + kvStore.synchronize() + + if let cloudValue = kvStore.string(forKey: key), Defaults[.paletteText].isEmpty { + lastCloudAppliedValue = cloudValue + Defaults[.paletteText] = cloudValue + } + } + + Defaults.observe(.paletteText) { [weak self] change in + guard let self = self else { return } + if change.newValue == self.lastCloudAppliedValue { + self.lastCloudAppliedValue = nil + return + } + self.store?.set(change.newValue, forKey: self.key) + }.tieToLifetime(of: self) + } + + /// Handles iCloud KVS external change notifications (server sync, initial sync, account change). + @objc private func cloudDidChange(_ notification: Notification) { + guard let store = store, + let userInfo = notification.userInfo, + let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int + else { return } + + switch reason { + case NSUbiquitousKeyValueStoreServerChange, + NSUbiquitousKeyValueStoreInitialSyncChange, + NSUbiquitousKeyValueStoreAccountChange: + if let cloudValue = store.string(forKey: key) { + if cloudValue != Defaults[.paletteText] { + lastCloudAppliedValue = cloudValue + } + Defaults[.paletteText] = cloudValue + } + case NSUbiquitousKeyValueStoreQuotaViolationChange: + quotaExceeded = true + NSLog("PaletteSyncManager: iCloud KVS quota exceeded") + default: + break + } + } +} diff --git a/Pika/Views/ColorHistory.swift b/Pika/Views/ColorHistory.swift new file mode 100644 index 00000000..1483e44a --- /dev/null +++ b/Pika/Views/ColorHistory.swift @@ -0,0 +1,24 @@ +import Defaults +import SwiftUI + +struct ColorHistory: View { + @Default(.colorHistory) var colorHistory + @Default(.showColorHistory) var showColorHistory + @EnvironmentObject var eyedroppers: Eyedroppers + + var body: some View { + let colors = Array(colorHistory.prefix(ColorHistoryManager.maxEntries)) + if showColorHistory, !colors.isEmpty { + Divider() + SwatchBar( + title: PikaText.textColorHistory, + swatches: colors.map { Swatch(id: $0, color: NSColor(hex: $0), hex: $0, name: nil) }, + onTap: { swatch in + eyedroppers.foreground.applyFromSwatch(swatch.color) + eyedroppers.foreground.promoteInHistory(hex: swatch.hex) + } + ) + .swatchSectionStyle() + } + } +} diff --git a/Pika/Views/ColorPaletteBar.swift b/Pika/Views/ColorPaletteBar.swift new file mode 100644 index 00000000..ab518acc --- /dev/null +++ b/Pika/Views/ColorPaletteBar.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct ColorPaletteBar: View { + let palette: ColorPalette + @EnvironmentObject var eyedroppers: Eyedroppers + + var body: some View { + SwatchBar( + title: palette.name, + // Index-based IDs because a palette can contain duplicate colors. + swatches: palette.colors.enumerated().map { index, color in + Swatch( + id: "\(palette.id):\(index)", + color: color.color, + hex: color.hex, + name: color.name + ) + }, + onTap: { swatch in + eyedroppers.foreground.applyFromSwatch(swatch.color) + } + ) + } +} diff --git a/Pika/Views/ColorPalettes.swift b/Pika/Views/ColorPalettes.swift new file mode 100644 index 00000000..9910b790 --- /dev/null +++ b/Pika/Views/ColorPalettes.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct ColorPalettes: View { + let palettes: [ColorPalette] + @EnvironmentObject var eyedroppers: Eyedroppers + + var body: some View { + ForEach(palettes) { palette in + Divider() + ColorPaletteBar(palette: palette) + .environmentObject(eyedroppers) + .swatchSectionStyle() + } + } +} diff --git a/Pika/Views/ContentView.swift b/Pika/Views/ContentView.swift index ce400ad8..70085b59 100644 --- a/Pika/Views/ContentView.swift +++ b/Pika/Views/ContentView.swift @@ -7,14 +7,22 @@ struct ContentView: View { @Default(.copyFormat) var copyFormat @Default(.colorFormat) var colorFormat + @Default(.paletteText) var paletteText @Environment(\.colorScheme) var colorScheme: ColorScheme let pasteboard = NSPasteboard.general + /// When provided (popover path), avoids re-parsing paletteText that PopoverContentView already parsed. + var externalPalettes: [ColorPalette]? + @State var swapVisible: Bool = false @State private var timerSubscription: Cancellable? @State private var timer = Timer.publish(every: 0.25, on: .main, in: .common) @State private var angle: Double = 0 + private var palettes: [ColorPalette] { + externalPalettes ?? PaletteParser.parse(paletteText) + } + var body: some View { VStack(alignment: .trailing, spacing: 0) { Divider() @@ -44,11 +52,6 @@ struct ContentView: View { alt: PikaText.textColorSwap, ltr: true )) - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name(PikaConstants.ncTriggerSwap))) - { _ in - swap(&eyedroppers.foreground.color, &eyedroppers.background.color) - } .focusable(false) .padding(16.0) .frame(maxHeight: .infinity, alignment: .top) @@ -63,11 +66,16 @@ struct ContentView: View { Divider() Footer(foreground: eyedroppers.foreground, background: eyedroppers.background) + ColorHistory() + ColorPalettes(palettes: palettes) } .onAppear { - eyedroppers.background.color = colorScheme == .light - ? NSColor.white - : NSColor.black + if !eyedroppers.hasSetInitialBackground { + eyedroppers.hasSetInitialBackground = true + eyedroppers.background.color = colorScheme == .light + ? NSColor.white + : NSColor.black + } } .onReceive(NotificationCenter.default.publisher( for: Notification.Name(PikaConstants.ncTriggerCopyText))) @@ -90,6 +98,23 @@ struct ContentView: View { } } +/// Shared layout constants for dynamic height calculation. +/// Used by both PopoverContentView and AppDelegate.updateWindowSize(). +enum SwatchLayout { + /// Height of a single swatch section (Divider + SwatchBar + padding). + static let swatchSectionHeight: CGFloat = 52 + static let maxHeight: CGFloat = 550 + + static func totalHeight(base: CGFloat, hasHistory: Bool, paletteCount: Int) -> CGFloat { + var height = base + if hasHistory { + height += swatchSectionHeight + } + height += CGFloat(paletteCount) * swatchSectionHeight + return min(height, maxHeight) + } +} + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index 5702f62b..78e1b84b 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -66,7 +66,7 @@ struct EyedropperButton: View { .focusable(false) Button(action: { - NSApp.sendAction(eyedropper.type.systemPickerSelector, to: nil, from: nil) + eyedropper.togglePicker() }, label: { IconImage(name: "paintpalette", resizable: true) .aspectRatio(contentMode: .fit) diff --git a/Pika/Views/EyedropperItem.swift b/Pika/Views/EyedropperItem.swift index a1555f66..a09f7be6 100644 --- a/Pika/Views/EyedropperItem.swift +++ b/Pika/Views/EyedropperItem.swift @@ -33,16 +33,6 @@ struct EyedropperItem: View { = "\(eyedropper.color.toFormat(format: colorFormat, style: Defaults[.copyFormat]))" pasteboard.setString(contents, forType: .string) } - .onReceive(NotificationCenter.default.publisher( - for: Notification.Name("triggerSystemPicker\(eyedropper.type.rawValue.capitalized)"))) - { _ in - let panel = NSColorPanel.shared - if panel.isVisible, panel.title == "\(eyedropper.type.rawValue.capitalized)" { - panel.close() - } else { - eyedropper.picker() - } - } .onReceive(NotificationCenter.default.publisher( for: Notification.Name("triggerFormatHex"))) { _ in diff --git a/Pika/Views/PaletteEditor.swift b/Pika/Views/PaletteEditor.swift new file mode 100644 index 00000000..e87e11e4 --- /dev/null +++ b/Pika/Views/PaletteEditor.swift @@ -0,0 +1,201 @@ +import Defaults +import SwiftUI + +/// NSTextView subclass that handles standard edit shortcuts directly, +/// bypassing the app's custom Edit menu which lacks Cut/Copy/Paste items. +class PasteableTextView: NSTextView { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + guard event.modifierFlags.contains(.command) else { + return super.performKeyEquivalent(with: event) + } + switch event.charactersIgnoringModifiers { + case "v": pasteAsPlainText(nil); return true + case "c": copy(nil); return true + case "x": cut(nil); return true + case "a": selectAll(nil); return true + default: return super.performKeyEquivalent(with: event) + } + } +} + +/// Wraps NSTextView because SwiftUI's TextEditor lacks control over autocorrect, +/// smart quotes, and text container insets on macOS. +struct PaletteTextView: NSViewRepresentable { + @Binding var text: String + var onTextChange: (() -> Void)? + + func makeNSView(context: Context) -> NSScrollView { + let textView = PasteableTextView() + textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + textView.isAutomaticSpellingCorrectionEnabled = false + textView.isAutomaticTextCompletionEnabled = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isRichText = false + textView.allowsUndo = true + textView.textContainerInset = NSSize(width: 4, height: 6) + textView.backgroundColor = .clear + textView.drawsBackground = false + textView.delegate = context.coordinator + textView.string = text + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + + let scrollView = NSScrollView() + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + return scrollView + } + + func updateNSView(_ nsView: NSScrollView, context _: Context) { + guard let textView = nsView.documentView as? NSTextView else { return } + if textView.string != text { + textView.string = text + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSTextViewDelegate { + var parent: PaletteTextView + init(_ parent: PaletteTextView) { self.parent = parent } + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + parent.onTextChange?() + } + } +} + +struct PaletteEditor: View { + @Default(.paletteText) var paletteText + @State private var statusText: String = "" + @State private var statusOpacity: Double = 1.0 + @State private var debounceWorkItem: DispatchWorkItem? + @State private var fadeWorkItem: DispatchWorkItem? + @Environment(\.colorScheme) var colorScheme: ColorScheme + + // swiftlint:disable line_length + private let exampleText = "[Pika]\n#7939F0, #D85685, #020202\n\n[Pika named]\n#7939F0(Primary), #D85685(Seconday), #020202(Background)\n\n[Pika any format]\nrgb(121, 57, 240), hsl(338, 63%, 59%), oklch(8.47% 0.0000 89.88)" + // swiftlint:enable line_length + + private var syncManager: PaletteSyncManager? { + (NSApp.delegate as? AppDelegate)?.paletteSyncManager + } + + var body: some View { + VStack(alignment: .leading, spacing: 8.0) { + Section( + header: Text(PikaText.textPalettesTitle).font( + .system(size: 16)) + ) { + VStack(alignment: .leading, spacing: 12.0) { + Text(PikaText.textPalettesDescription).font( + .system(size: 13, weight: .medium)) + + PaletteTextView(text: $paletteText) { + onTextEdited() + } + .frame(height: 120) + .background(Color(NSColor.controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 4.0)) + .overlay(RoundedRectangle(cornerRadius: 4.0) + .stroke(Color.primary.opacity(0.1))) + + Text(statusText) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .opacity(statusOpacity) + .animation(.easeInOut(duration: 0.3), value: statusOpacity) + + VStack(alignment: .leading, spacing: 6.0) { + Text("Example") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.secondary) + Text(exampleText) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8.0) + .padding(.vertical, 8.0) + .frame(maxWidth: .infinity, alignment: .leading) + .clipShape(RoundedRectangle(cornerRadius: 4.0)) + .background(RoundedRectangle(cornerRadius: 4.0).fill( + colorScheme == .dark + ? .black.opacity(0.1) + : .white.opacity(0.2) + )) + .overlay(RoundedRectangle(cornerRadius: 4.0) + .stroke(Color.primary.opacity(0.1))) + } + } + } + .onAppear { statusText = restingStatus() } + } + + /// Called on each text edit. Debounces, then shows "Saved" briefly before + /// fading through transparent back to the resting status. + private func onTextEdited() { + debounceWorkItem?.cancel() + fadeWorkItem?.cancel() + + let work = DispatchWorkItem { + let resting = restingStatus() + + // If there are validation warnings, show them immediately (no "Saved" flash). + if let warning = PaletteParser.validate(paletteText) { + transitionStatus(to: warning) + return + } + + // Flash "Saved" then fade back to resting status. + transitionStatus(to: "Saved") { + let fade = DispatchWorkItem { + transitionStatus(to: resting) + } + fadeWorkItem = fade + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: fade) + } + } + debounceWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work) + } + + /// Fades out, swaps text, fades in. Calls completion after fade-in. + private func transitionStatus(to newText: String, completion: (() -> Void)? = nil) { + // Fade out + withAnimation(.easeInOut(duration: 0.25)) { + statusOpacity = 0.0 + } + // Swap text and fade in after fade-out completes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + statusText = newText + withAnimation(.easeInOut(duration: 0.25)) { + statusOpacity = 1.0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + completion?() + } + } + } + + /// The permanent resting message based on iCloud state. + private func restingStatus() -> String { + guard let manager = syncManager else { + return "Saved locally" + } + if !manager.iCloudAvailable { + return "iCloud unavailable" + } + if manager.quotaExceeded { + return "iCloud quota exceeded" + } + return "Synced via iCloud" + } +} diff --git a/Pika/Views/PreferencesView.swift b/Pika/Views/PreferencesView.swift index 6b9f209e..275faa09 100644 --- a/Pika/Views/PreferencesView.swift +++ b/Pika/Views/PreferencesView.swift @@ -16,6 +16,7 @@ struct PreferencesView: View { @Default(.appFloating) var appFloating @Default(.alwaysShowOnLaunch) var alwaysShowOnLaunch @Default(.contrastStandard) var contrastStandard + @Default(.showColorHistory) var showColorHistory @Default(.showColorOverlay) var showColorOverlay @Default(.colorOverlayDuration) var colorOverlayDuration @State var colorSpace: NSColorSpace = Defaults[.colorSpace] @@ -106,6 +107,9 @@ struct PreferencesView: View { ) } #endif + Toggle(isOn: $showColorHistory) { + Text(PikaText.textShowColorHistory) + } if appMode != .menubar { Toggle(isOn: $disableHideMenuBarIcon) { Text(PikaText.textIconDescription) @@ -350,7 +354,14 @@ struct PreferencesView: View { } .padding(.horizontal, 24.0) } - .padding(.bottom, 24.0) + Divider() + .padding(.vertical, 16.0) + + // Color Palettes + + PaletteEditor() + .padding(.horizontal, 24.0) + .padding(.bottom, 24.0) } .background( GeometryReader { contentGeometry in diff --git a/Pika/Views/SwatchBar.swift b/Pika/Views/SwatchBar.swift new file mode 100644 index 00000000..3b5d0934 --- /dev/null +++ b/Pika/Views/SwatchBar.swift @@ -0,0 +1,101 @@ +import Defaults +import SwiftUI + +struct Swatch: Identifiable, Equatable { + let id: String + /// Full-precision color used for rendering and format conversion. + let color: NSColor + /// Hex identifier used for color history lookups and tap-to-copy. + let hex: String + let name: String? + + static func == (lhs: Swatch, rhs: Swatch) -> Bool { + lhs.id == rhs.id + } +} + +/// Shared component for color history and palette bars: equal-width colored rectangles +/// with hover text and a tap callback. Uses GeometryReader to divide available width evenly. +/// Hover text dynamically reflects the currently selected color format. +struct SwatchBar: View { + let title: String + let swatches: [Swatch] + let onTap: (Swatch) -> Void + + @Default(.colorFormat) var colorFormat + @State private var hoveredSwatch: Swatch? + @State private var isHoveringBar = false + + private func hoverText(for swatch: Swatch) -> String { + // Hex keeps '#'; other formats show just the values (no function wrapper). + let style: CopyFormat = colorFormat == .hex ? .css : .unformatted + let formatted = swatch.color.toFormat(format: colorFormat, style: style) + if let name = swatch.name { + return "\(name) (\(formatted))" + } + return formatted + } + + var body: some View { + VStack(alignment: .leading, spacing: 4.0) { + HStack { + Text(title) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + Spacer() + + Text(hoveredSwatch.map { hoverText(for: $0) } ?? " ") + .font(.caption) + .foregroundColor(.secondary) + .opacity(isHoveringBar ? 1 : 0) + .animation(.easeInOut(duration: 0.15), value: isHoveringBar) + } + .padding(.horizontal, 12.0) + + GeometryReader { geometry in + HStack(spacing: 0) { + ForEach(swatches) { swatch in + Rectangle() + .fill(Color(swatch.color)) + .frame(width: geometry.size.width / CGFloat(swatches.count)) + .onHover { hovering in + if hovering { hoveredSwatch = swatch } + } + .onTapGesture { + onTap(swatch) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 4.0)) + // Keep hoveredSwatch set on exit so the text doesn't change width + // during the opacity fade-out (which would cause a visible slide). + .onHover { hovering in + isHoveringBar = hovering + } + } + .frame(height: 16) + .padding(.horizontal, 12.0) + } + } +} + +/// Shared styling for swatch sections (color history and palette bars). +struct SwatchSectionStyle: ViewModifier { + func body(content: Content) -> some View { + content + .padding(.top, 10.0) + .padding(.bottom, 12.0) + .background(VisualEffect( + material: NSVisualEffectView.Material.underWindowBackground, + blendingMode: NSVisualEffectView.BlendingMode.behindWindow + )) + } +} + +extension View { + func swatchSectionStyle() -> some View { + modifier(SwatchSectionStyle()) + } +}