diff --git a/Pika.xcodeproj/project.pbxproj b/Pika.xcodeproj/project.pbxproj index 93438613..e4ffbed0 100644 --- a/Pika.xcodeproj/project.pbxproj +++ b/Pika.xcodeproj/project.pbxproj @@ -28,9 +28,11 @@ CC2000000000000000000901 /* NotificationNamesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000900 /* NotificationNamesTests.swift */; }; CC2000000000000000000A01 /* CGFloatFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000A00 /* CGFloatFormatTests.swift */; }; CC2000000000000000000B01 /* ColorPairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000B00 /* ColorPairTests.swift */; }; + DEC0FACE0000000000000011 /* ColorDecompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0FACE0000000000000010 /* ColorDecompositionTests.swift */; }; CC2000000000000000000C01 /* ClosestVectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000C00 /* ClosestVectorTests.swift */; }; CC2000000000000000000D01 /* SequenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000D00 /* SequenceTests.swift */; }; CC2000000000000000000E01 /* ExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000E00 /* ExporterTests.swift */; }; + CC2000000000000000000F01 /* EditableColorValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2000000000000000000F00 /* EditableColorValueTests.swift */; }; 220D5E9428DB154300B6285E /* AppModeButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9328DB154300B6285E /* AppModeButtons.swift */; }; 220D5E9828DB158400B6285E /* AppModeToggleGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220D5E9728DB158400B6285E /* AppModeToggleGroup.swift */; }; @@ -63,6 +65,8 @@ CC000001000000000000AB12 /* CGFloat+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001000000000000AB10 /* CGFloat+Format.swift */; }; EA03E39B2F5E618E00998D8B /* ColorPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA03E39A2F5E618E00998D8B /* ColorPair.swift */; }; EA03E39C2F5E618E00998D8B /* ColorPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA03E39A2F5E618E00998D8B /* ColorPair.swift */; }; + DEC0FACE0000000000000002 /* ColorDecomposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0FACE0000000000000001 /* ColorDecomposition.swift */; }; + DEC0FACE0000000000000003 /* ColorDecomposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0FACE0000000000000001 /* ColorDecomposition.swift */; }; EA03E39E2F5E619800998D8B /* ColorHistoryDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA03E39D2F5E619800998D8B /* ColorHistoryDrawer.swift */; }; 5A49CEF4F87747CDB61A3CBD /* PaletteComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35086BCEF529498582A432A1 /* PaletteComponents.swift */; }; EA03E39F2F5E619800998D8B /* ColorHistoryDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA03E39D2F5E619800998D8B /* ColorHistoryDrawer.swift */; }; @@ -73,6 +77,9 @@ EA0C526025AB5A2B00AFF716 /* NavigationMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */; }; EA0C526425AB5D1700AFF716 /* PikaWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C526325AB5D1700AFF716 /* PikaWindow.swift */; }; EA0C526F25AB683400AFF716 /* EyedropperButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C526E25AB683400AFF716 /* EyedropperButton.swift */; }; + DEC0FACE0000000000000022 /* EditableColorValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0FACE0000000000000020 /* EditableColorValue.swift */; }; + 8B7DF0908AB8B8B9BA673C6C /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53C02D08D06EB2DC10A9D8B /* FlowLayout.swift */; }; + 6BA24CD764F102150B101D23 /* ScrubTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71CF5EAC0C44F25B406AAFD /* ScrubTextField.swift */; }; EA136072284887E9004F1630 /* AppearanceButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA136071284887E9004F1630 /* AppearanceButtonStyle.swift */; }; EA257BD125D8629300C3FC54 /* SwapButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA257BD025D8629300C3FC54 /* SwapButtonStyle.swift */; }; EA424C7D25CDEF98009056A9 /* ComplianceToggleGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA424C7C25CDEF98009056A9 /* ComplianceToggleGroup.swift */; }; @@ -159,6 +166,9 @@ EAE23DC52D032A38005BB270 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD0B6F3259CF29300FA2F67 /* AboutView.swift */; }; EAE23DC62D032A38005BB270 /* KeyboardShortcutKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA8AE1825B8EC070049299B /* KeyboardShortcutKey.swift */; }; EAE23DC72D032A38005BB270 /* EyedropperButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0C526E25AB683400AFF716 /* EyedropperButton.swift */; }; + DEC0FACE0000000000000023 /* EditableColorValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC0FACE0000000000000020 /* EditableColorValue.swift */; }; + FA970E37631A882EE51A223F /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C53C02D08D06EB2DC10A9D8B /* FlowLayout.swift */; }; + AF6D70BF5DEF9B9AFD50EAC0 /* ScrubTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71CF5EAC0C44F25B406AAFD /* ScrubTextField.swift */; }; EAE23DC82D032A38005BB270 /* MetalShader.metal in Sources */ = {isa = PBXBuildFile; fileRef = EA72BB8225A5334B008205E7 /* MetalShader.metal */; }; EAE23DC92D032A38005BB270 /* SwapButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA257BD025D8629300C3FC54 /* SwapButtonStyle.swift */; }; EAE23DCA2D032A38005BB270 /* NSWindowFade.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA72BBA825A7CE9C008205E7 /* NSWindowFade.swift */; }; @@ -193,6 +203,14 @@ 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 */; }; + CCB1CA0000000000000000F2 /* ColorPickSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA0000000000000000F1 /* ColorPickSession.swift */; }; + CCB1CA0000000000000000F3 /* ColorPickSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA0000000000000000F1 /* ColorPickSession.swift */; }; + CCB1CA0000000000000000F5 /* CustomColorPickSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA0000000000000000F4 /* CustomColorPickSession.swift */; }; + CCB1CA0000000000000000F6 /* CustomColorPickSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA0000000000000000F4 /* CustomColorPickSession.swift */; }; + CCB1CA0000000000000000F8 /* PickerLoupePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA0000000000000000F7 /* PickerLoupePanel.swift */; }; + CCB1CA0000000000000000F9 /* PickerLoupePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA0000000000000000F7 /* PickerLoupePanel.swift */; }; + CCB1CA000000000000000101 /* PickerLoupeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA000000000000000100 /* PickerLoupeView.swift */; }; + CCB1CA000000000000000102 /* PickerLoupeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCB1CA000000000000000100 /* PickerLoupeView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -232,9 +250,11 @@ CC2000000000000000000900 /* NotificationNamesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationNamesTests.swift; sourceTree = ""; }; CC2000000000000000000A00 /* CGFloatFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGFloatFormatTests.swift; sourceTree = ""; }; CC2000000000000000000B00 /* ColorPairTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPairTests.swift; sourceTree = ""; }; + DEC0FACE0000000000000010 /* ColorDecompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorDecompositionTests.swift; sourceTree = ""; }; CC2000000000000000000C00 /* ClosestVectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosestVectorTests.swift; sourceTree = ""; }; CC2000000000000000000D00 /* SequenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceTests.swift; sourceTree = ""; }; CC2000000000000000000E00 /* ExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExporterTests.swift; sourceTree = ""; }; + CC2000000000000000000F00 /* EditableColorValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableColorValueTests.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 = ""; }; @@ -267,6 +287,7 @@ CC000001000000000000AB00 /* NSColor+RGB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+RGB.swift"; sourceTree = ""; }; CC000001000000000000AB10 /* CGFloat+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGFloat+Format.swift"; sourceTree = ""; }; EA03E39A2F5E618E00998D8B /* ColorPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPair.swift; sourceTree = ""; }; + DEC0FACE0000000000000001 /* ColorDecomposition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorDecomposition.swift; sourceTree = ""; }; EA03E39D2F5E619800998D8B /* ColorHistoryDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorHistoryDrawer.swift; sourceTree = ""; }; 35086BCEF529498582A432A1 /* PaletteComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaletteComponents.swift; sourceTree = ""; }; EA03E3A02F5E619800998D8B /* ColorPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPreview.swift; sourceTree = ""; }; @@ -274,6 +295,9 @@ EA0C525F25AB5A2B00AFF716 /* NavigationMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationMenuItems.swift; sourceTree = ""; }; EA0C526325AB5D1700AFF716 /* PikaWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PikaWindow.swift; sourceTree = ""; }; EA0C526E25AB683400AFF716 /* EyedropperButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EyedropperButton.swift; sourceTree = ""; }; + DEC0FACE0000000000000020 /* EditableColorValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableColorValue.swift; sourceTree = ""; }; + C53C02D08D06EB2DC10A9D8B /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = ""; }; + B71CF5EAC0C44F25B406AAFD /* ScrubTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrubTextField.swift; sourceTree = ""; }; EA0F36AE2929138E00BFC7EB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; EA0F36B02929139000BFC7EB /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; EA136071284887E9004F1630 /* AppearanceButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppearanceButtonStyle.swift; sourceTree = ""; }; @@ -339,6 +363,10 @@ EAF100CC25C785C4006E1EC3 /* TouchBarVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBarVisual.swift; sourceTree = ""; }; F8ABAC592EAAD0DF008CD152 /* ColorPickOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickOverlay.swift; sourceTree = ""; }; F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickOverlayWindow.swift; sourceTree = ""; }; + CCB1CA0000000000000000F1 /* ColorPickSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickSession.swift; sourceTree = ""; }; + CCB1CA0000000000000000F4 /* CustomColorPickSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomColorPickSession.swift; sourceTree = ""; }; + CCB1CA0000000000000000F7 /* PickerLoupePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerLoupePanel.swift; sourceTree = ""; }; + CCB1CA000000000000000100 /* PickerLoupeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerLoupeView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -493,6 +521,9 @@ isa = PBXGroup; children = ( EA03E39A2F5E618E00998D8B /* ColorPair.swift */, + DEC0FACE0000000000000001 /* ColorDecomposition.swift */, + CCB1CA0000000000000000F1 /* ColorPickSession.swift */, + CCB1CA0000000000000000F4 /* CustomColorPickSession.swift */, CC1100000000000000000007 /* Models */, CC1100000000000000000008 /* Export */, CC1100000000000000000009 /* Windows */, @@ -510,6 +541,7 @@ children = ( EA03E39D2F5E619800998D8B /* ColorHistoryDrawer.swift */, 35086BCEF529498582A432A1 /* PaletteComponents.swift */, + CCB1CA000000000000000100 /* PickerLoupeView.swift */, EA03E3A02F5E619800998D8B /* ColorPreview.swift */, EAD0B6F6259CF29300FA2F67 /* ContentView.swift */, CCFEED0010000000000000B1 /* PopoverContentView.swift */, @@ -550,9 +582,11 @@ CC2000000000000000000900 /* NotificationNamesTests.swift */, CC2000000000000000000A00 /* CGFloatFormatTests.swift */, CC2000000000000000000B00 /* ColorPairTests.swift */, + DEC0FACE0000000000000010 /* ColorDecompositionTests.swift */, CC2000000000000000000C00 /* ClosestVectorTests.swift */, CC2000000000000000000D00 /* SequenceTests.swift */, CC2000000000000000000E00 /* ExporterTests.swift */, + CC2000000000000000000F00 /* EditableColorValueTests.swift */, ); path = PikaTests; sourceTree = ""; @@ -563,6 +597,9 @@ children = ( EA635DE025B4FC580014D91A /* ColorPickers.swift */, EA0C526E25AB683400AFF716 /* EyedropperButton.swift */, + DEC0FACE0000000000000020 /* EditableColorValue.swift */, + C53C02D08D06EB2DC10A9D8B /* FlowLayout.swift */, + B71CF5EAC0C44F25B406AAFD /* ScrubTextField.swift */, EA635DE925B534C80014D91A /* EyedropperItem.swift */, ); name = Eyedropper; @@ -648,6 +685,7 @@ isa = PBXGroup; children = ( F8ABAC5C2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift */, + CCB1CA0000000000000000F7 /* PickerLoupePanel.swift */, EA0C526325AB5D1700AFF716 /* PikaWindow.swift */, CC1100000000000000000101 /* WindowCoordinator.swift */, ); @@ -948,6 +986,7 @@ EAF100CD25C785C4006E1EC3 /* TouchBarVisual.swift in Sources */, EA801285259F8F480026D5D9 /* ComplianceToggle.swift in Sources */, EA03E39B2F5E618E00998D8B /* ColorPair.swift in Sources */, + DEC0FACE0000000000000002 /* ColorDecomposition.swift in Sources */, 221600FD25A636D600B8B7D9 /* ConditionalModifier.swift in Sources */, EA03E39E2F5E619800998D8B /* ColorHistoryDrawer.swift in Sources */, 5A49CEF4F87747CDB61A3CBD /* PaletteComponents.swift in Sources */, @@ -971,12 +1010,19 @@ CC000001000000000000AB11 /* CGFloat+Format.swift in Sources */, EAA8AE1925B8EC070049299B /* KeyboardShortcutKey.swift in Sources */, EA0C526F25AB683400AFF716 /* EyedropperButton.swift in Sources */, + DEC0FACE0000000000000022 /* EditableColorValue.swift in Sources */, + 8B7DF0908AB8B8B9BA673C6C /* FlowLayout.swift in Sources */, + 6BA24CD764F102150B101D23 /* ScrubTextField.swift in Sources */, EA72BB8425A5334B008205E7 /* MetalShader.metal in Sources */, EA257BD125D8629300C3FC54 /* SwapButtonStyle.swift in Sources */, EA72BBA925A7CE9C008205E7 /* NSWindowFade.swift in Sources */, EAD0B718259D146200FA2F67 /* EyedropperButtonStyle.swift in Sources */, EAEBF64725E878A5002999D1 /* CircleButtonStyle.swift in Sources */, F8ABAC5D2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift in Sources */, + CCB1CA0000000000000000F2 /* ColorPickSession.swift in Sources */, + CCB1CA0000000000000000F5 /* CustomColorPickSession.swift in Sources */, + CCB1CA0000000000000000F8 /* PickerLoupePanel.swift in Sources */, + CCB1CA000000000000000101 /* PickerLoupeView.swift in Sources */, EA635DE125B4FC580014D91A /* ColorPickers.swift in Sources */, EACA8A45260501210064035C /* Exporter.swift in Sources */, 221600F925A62E5B00B8B7D9 /* IconImage.swift in Sources */, @@ -1023,9 +1069,14 @@ EAE23DB02D032A38005BB270 /* KeyboardShortcutGrid.swift in Sources */, EAE2EBA32D03C40000FA9BC9 /* LatestAppStoreVersion+ShouldUpdate.swift in Sources */, F8ABAC5E2EAAD0F0008CD152 /* ColorPickOverlayWindow.swift in Sources */, + CCB1CA0000000000000000F3 /* ColorPickSession.swift in Sources */, + CCB1CA0000000000000000F6 /* CustomColorPickSession.swift in Sources */, + CCB1CA0000000000000000F9 /* PickerLoupePanel.swift in Sources */, + CCB1CA000000000000000102 /* PickerLoupeView.swift in Sources */, EAE23DB12D032A38005BB270 /* AppModeToggleGroup.swift in Sources */, EAE23DB22D032A38005BB270 /* Visualisation.swift in Sources */, EA03E39C2F5E618E00998D8B /* ColorPair.swift in Sources */, + DEC0FACE0000000000000003 /* ColorDecomposition.swift in Sources */, EAE23DB32D032A38005BB270 /* ContentView.swift in Sources */, EAE23DB42D032A38005BB270 /* AppDelegate.swift in Sources */, CCFEED0003000000000000A3 /* PikaApp.swift in Sources */, @@ -1054,6 +1105,9 @@ EAE23DC62D032A38005BB270 /* KeyboardShortcutKey.swift in Sources */, F8ABAC5B2EAAD0DF008CD152 /* ColorPickOverlay.swift in Sources */, EAE23DC72D032A38005BB270 /* EyedropperButton.swift in Sources */, + DEC0FACE0000000000000023 /* EditableColorValue.swift in Sources */, + FA970E37631A882EE51A223F /* FlowLayout.swift in Sources */, + AF6D70BF5DEF9B9AFD50EAC0 /* ScrubTextField.swift in Sources */, EAE23DC82D032A38005BB270 /* MetalShader.metal in Sources */, EAE2EB9F2D03C3DE00FA9BC9 /* LookUpAPI.swift in Sources */, EAE23DC92D032A38005BB270 /* SwapButtonStyle.swift in Sources */, @@ -1092,9 +1146,11 @@ CC2000000000000000000901 /* NotificationNamesTests.swift in Sources */, CC2000000000000000000A01 /* CGFloatFormatTests.swift in Sources */, CC2000000000000000000B01 /* ColorPairTests.swift in Sources */, + DEC0FACE0000000000000011 /* ColorDecompositionTests.swift in Sources */, CC2000000000000000000C01 /* ClosestVectorTests.swift in Sources */, CC2000000000000000000D01 /* SequenceTests.swift in Sources */, CC2000000000000000000E01 /* ExporterTests.swift in Sources */, + CC2000000000000000000F01 /* EditableColorValueTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1266,7 +1322,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 101; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Pika/Preview Content\""; DEVELOPMENT_TEAM = TGHU37N6EX; @@ -1280,7 +1336,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 2.0.0-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG TARGET_SPARKLE"; @@ -1298,7 +1354,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 97; + CURRENT_PROJECT_VERSION = 101; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Pika/Preview Content\""; DEVELOPMENT_TEAM = TGHU37N6EX; @@ -1312,7 +1368,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 2.0.0-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TARGET_SPARKLE; @@ -1345,7 +1401,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 2.0.0-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG TARGET_MAS"; @@ -1378,7 +1434,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.9.0; + MARKETING_VERSION = 2.0.0-beta3; PRODUCT_BUNDLE_IDENTIFIER = com.superhighfives.Pika; PRODUCT_NAME = Pika; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TARGET_MAS; @@ -1471,7 +1527,7 @@ repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.0.0; + minimumVersion = 2.4.0; }; }; EAD0B6E7259CEF8600FA2F67 /* XCRemoteSwiftPackageReference "Sparkle" */ = { diff --git a/Pika.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Pika.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 604afb82..f0f13a05 100644 --- a/Pika.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Pika.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/KeyboardShortcuts", "state" : { - "revision" : "045cf174010beb335fa1d2567d18c057b8787165", - "version" : "2.3.0" + "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", + "version" : "2.4.0" } }, { diff --git a/Pika/AppDelegate.swift b/Pika/AppDelegate.swift index f3245393..4c1d68b9 100644 --- a/Pika/AppDelegate.swift +++ b/Pika/AppDelegate.swift @@ -82,6 +82,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { validateColorSpace() showPikaIfConfigured() registerGlobalKeyMonitor() + + // Refresh the colour-name list from color.pizza (catalogue + selected list) and cache + // it; the eyedroppers rebuild via `.colorNamesUpdated`. Best-effort — falls back to + // the bundled default offline. + ColorNamesManager.shared.updateOnLaunch() } private func removeUpdatesMenuItemIfNeeded() { @@ -113,10 +118,27 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApp.sendAction(#selector(AppDelegate.triggerPickForeground), to: nil, from: nil) } } + KeyboardShortcuts.onKeyUp(for: .pickPair) { [] in + if Defaults[.viewedSplash] { + NSApp.sendAction(#selector(AppDelegate.triggerPickContrast), to: nil, from: nil) + } + } + } + + /// The splash shows on every launch unless the user ticked its (pre-selected) "Don't show + /// this again" checkbox — EXCEPT when a new onboarding version hasn't been seen yet, in which + /// case it shows once for everyone (see `PikaConstants.currentSplashVersion`). + private var shouldShowSplash: Bool { + !Defaults[.hideSplashOnLaunch] + || Defaults[.lastSeenSplashVersion] < PikaConstants.currentSplashVersion } private func presentSplashIfNeeded() { - if !Defaults[.viewedSplash] { + // `viewedSplash` still records the first run so the pick shortcuts stay gated until + // onboarding is dismissed the first time. `lastSeenSplashVersion` is recorded on + // dismissal (`closeSplashWindow`), not here — recording it now would make + // `showPikaIfConfigured` reveal the main window behind the splash. + if shouldShowSplash { openSplashWindow(nil) NSApp.activate(ignoringOtherApps: true) } @@ -129,6 +151,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func showPikaIfConfigured() { + // Pika should only ever appear once the splash has been dismissed. When the splash + // is up, defer the main window until `closeSplashWindow` fires; otherwise show it now. + guard !shouldShowSplash else { return } + presentConfiguredPika() + } + + private func presentConfiguredPika() { if Defaults[.alwaysShowOnLaunch], !Defaults[.appMode].usesPopover { showPika(self) } @@ -215,7 +244,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Window forwarding extension AppDelegate { - @objc func closeSplashWindow() { windowCoordinator.closeSplashWindow() } + @objc func closeSplashWindow() { + windowCoordinator.closeSplashWindow() + // Record that this onboarding version has been seen, so the version gate doesn't + // re-show it next launch (the user's "Don't show again" choice governs from here). + Defaults[.lastSeenSplashVersion] = PikaConstants.currentSplashVersion + // Now that onboarding is dismissed, show Pika if the user has it set to launch shown. + presentConfiguredPika() + } + @objc func togglePopover(_: AnyObject?) { windowCoordinator.togglePopover() } @IBAction func openAboutWindow(_: Any?) { windowCoordinator.openAboutWindow() } @@ -240,6 +277,7 @@ extension AppDelegate { @IBAction func triggerPickContrast(_: Any) { notificationCenter.post(name: .triggerPickForeground, object: self, userInfo: ["chain": true]) + notificationCenter.post(name: .triggerPickPair, object: self) } @IBAction func triggerCopyForeground(_: Any) { @@ -286,12 +324,30 @@ extension AppDelegate { notificationCenter.post(name: .triggerSwap, object: self) } + /// The `NSTextView` field editor of whichever `ScrubTextField` is currently being edited, if + /// any — `ScrubTextField.becomeFirstResponder` makes the field editor (not the field itself) + /// the window's first responder while editing, so this is the one place both `triggerUndo` + /// and `triggerRedo` need to check to keep Cmd-Z scoped to "whatever the user is actually + /// looking at": a value being typed, if one is focused, otherwise colour-pick history. + private var focusedFieldUndoManager: UndoManager? { + guard let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.isFieldEditor else { return nil } + return editor.undoManager + } + @IBAction func triggerUndo(_: Any) { + if let manager = focusedFieldUndoManager, manager.canUndo { + manager.undo() + return + } notificationCenter.post(name: .triggerUndo, object: self) eyedroppers.undo() } @IBAction func triggerRedo(_: Any) { + if let manager = focusedFieldUndoManager, manager.canRedo { + manager.redo() + return + } notificationCenter.post(name: .triggerRedo, object: self) eyedroppers.redo() } diff --git a/Pika/Assets/en.lproj/Localizable.strings b/Pika/Assets/en.lproj/Localizable.strings index 4b9c48f8..29439729 100644 --- a/Pika/Assets/en.lproj/Localizable.strings +++ b/Pika/Assets/en.lproj/Localizable.strings @@ -202,6 +202,9 @@ /* Background */ "color.background" = "Background"; +/* Invalid input */ +"color.edit.invalid" = "Invalid input"; + /* WCAG 3:1 */ "color.wcag.30" = "WCAG 2 level AA requires a contrast ratio of at least 3:1 for large (18pt / ~24px) or bold (14pt / ~18.6px) text, and for non-text content like UI."; diff --git a/Pika/Constants/Constants.swift b/Pika/Constants/Constants.swift index 75d056ea..cb8a4992 100644 --- a/Pika/Constants/Constants.swift +++ b/Pika/Constants/Constants.swift @@ -4,6 +4,9 @@ import SwiftUI extension KeyboardShortcuts.Name { static let togglePika = Self("togglePika") + // Global, user-rebindable "pick a pair" shortcut (foreground → background chain). + // Defaults to ⌥⌘D, matching the in-window menu equivalent it replaces. + static let pickPair = Self("pickPair", default: .init(.d, modifiers: [.command, .option])) } enum PikaConstants { @@ -14,6 +17,11 @@ enum PikaConstants { : "https://superhighfives.com/releases/pika" } + // Bump when a release has onboarding worth re-showing: the splash then appears once for + // everyone (even those who ticked "Don't show again") whose `lastSeenSplashVersion` is lower. + // v1: the custom (Pro) colour picker. + static let currentSplashVersion = 1 + static let pikaWebsiteURL = "https://superhighfives.com/pika" static let gitHubRepoURL = "https://github.com/superhighfives/pika" static let gitHubIssueURL = "https://github.com/superhighfives/pika/issues/new/choose" @@ -42,6 +50,7 @@ enum PikaConstants { static let ncTriggerCopyData = "triggerCopyData" static let ncTriggerPickForeground = "triggerPickForeground" static let ncTriggerPickBackground = "triggerPickBackground" + static let ncTriggerPickPair = "triggerPickPair" static let ncTriggerSystemPickerForeground = "triggerSystemPickerForeground" static let ncTriggerSystemPickerBackground = "triggerSystemPickerBackground" static let ncTriggerSwap = "triggerSwap" @@ -67,6 +76,7 @@ enum PikaConstants { static let ncExportPalette = "exportPalette" static let ncSystemColorChanged = "systemColorChanged" static let ncExpandToFit = "expandToFit" + static let ncColorNamesUpdated = "colorNamesUpdated" // Disabled formats for SwiftUI copy format static let disabledFormats: [ColorFormat] = [.hex, .hsl, .opengl, .lab, .oklch] @@ -75,6 +85,9 @@ enum PikaConstants { extension Notification.Name { static let triggerPickForeground = Notification.Name(PikaConstants.ncTriggerPickForeground) static let triggerPickBackground = Notification.Name(PikaConstants.ncTriggerPickBackground) + /// Help-grid-only: fired alongside the chained `.triggerPickForeground` post so + /// "Pick pair"'s own row highlights instead of piggybacking on "Pick foreground"'s. + static let triggerPickPair = Notification.Name(PikaConstants.ncTriggerPickPair) static let triggerCopyForeground = Notification.Name(PikaConstants.ncTriggerCopyForeground) static let triggerCopyBackground = Notification.Name(PikaConstants.ncTriggerCopyBackground) static let triggerCopyText = Notification.Name(PikaConstants.ncTriggerCopyText) @@ -104,4 +117,5 @@ extension Notification.Name { static let exportPalette = Notification.Name(PikaConstants.ncExportPalette) static let systemColorChanged = Notification.Name(PikaConstants.ncSystemColorChanged) static let expandToFit = Notification.Name(PikaConstants.ncExpandToFit) + static let colorNamesUpdated = Notification.Name(PikaConstants.ncColorNamesUpdated) } diff --git a/Pika/Constants/Defaults.swift b/Pika/Constants/Defaults.swift index 72fb9858..74d84e92 100644 --- a/Pika/Constants/Defaults.swift +++ b/Pika/Constants/Defaults.swift @@ -60,6 +60,25 @@ enum WindowShadow: String, Codable, CaseIterable { var showsShadowAtRest: Bool { self != .never } } +enum PickerStyle: String, Codable, CaseIterable, Equatable { + case system // NSColorSampler (default, today's behaviour) + case custom // Pika-native loupe +} + +enum LoupeTheme: String, Codable, CaseIterable, Equatable { + case lens // colour-filled rim engraved with the format + colour name (SF Pro) + case badge // white rounded badges hugging the inside edge (monospaced) + case card // plain magnifier with a readout card beside it + + var localizedName: String { + switch self { + case .lens: return PikaText.textLoupeThemeLens + case .badge: return PikaText.textLoupeThemeBadge + case .card: return PikaText.textLoupeThemeCard + } + } +} + enum AppMode: String, Codable, CaseIterable { case menubar = "preferences.app.mode.menubar" case regular = "preferences.app.mode.regular" @@ -84,9 +103,21 @@ enum AppMode: String, Codable, CaseIterable { extension Defaults.Keys { static let colorFormat = Key("colorFormat", default: .hex) static let viewedSplash = Key("viewedSplash", default: false) + // The colour-name list to use, keyed to color.pizza's `/v1/lists/`. `default` is the + // list bundled at build time as the offline fallback. + static let colorNameList = Key("colorNameList", default: "default") + // When false, the splash is shown on launch. The splash's pre-selected "Don't show + // this again" checkbox sets this to true on dismissal. + static let hideSplashOnLaunch = Key("hideSplashOnLaunch", default: false) + // The `PikaConstants.currentSplashVersion` last shown to the user. Bumping that constant + // re-shows the splash once for everyone (even those who ticked "Don't show again"), so a + // release with new onboarding gets in front of existing users. + static let lastSeenSplashVersion = Key("lastSeenSplashVersion", default: 0) static let hidePikaWhilePicking = Key("hidePikaWhilePicking", default: false) static let windowShadow = Key("windowShadow", default: .always) static let pickContrastingColor = Key("pickContrastingColor", default: false) + static let pickerStyle = Key("pickerStyle", default: .system) + static let loupeTheme = Key("loupeTheme", default: .lens) static let copyColorOnPick = Key("copyColorOnPick", default: false) static let hideMenuBarIcon = Key("hideMenuBarIcon", default: false) static let betaUpdates = Key("betaUpdates", default: false) diff --git a/Pika/Constants/PikaShortcuts.swift b/Pika/Constants/PikaShortcuts.swift index 08ebca91..456cb796 100644 --- a/Pika/Constants/PikaShortcuts.swift +++ b/Pika/Constants/PikaShortcuts.swift @@ -13,6 +13,29 @@ struct PikaShortcut { let modifiers: NSEvent.ModifierFlags let action: Selector let notificationName: Notification.Name + /// True when this shortcut is also registered as a global `KeyboardShortcuts` + /// hotkey (see `AppDelegate.registerTogglePikaShortcut`). Such shortcuts fire + /// regardless of focus, so the popover local monitor must NOT dispatch them + /// again — otherwise the action double-fires. Still shown in the Help grid. + let hasGlobalBinding: Bool + + init( + title: String, + displayKeys: [String], + character: String, + modifiers: NSEvent.ModifierFlags, + action: Selector, + notificationName: Notification.Name, + hasGlobalBinding: Bool = false + ) { + self.title = title + self.displayKeys = displayKeys + self.character = character + self.modifiers = modifiers + self.action = action + self.notificationName = notificationName + self.hasGlobalBinding = hasGlobalBinding + } } enum PikaShortcuts { @@ -29,6 +52,16 @@ enum PikaShortcuts { action: #selector(AppDelegate.triggerPickBackground), notificationName: .triggerPickBackground ), + PikaShortcut( + title: PikaText.textPickPair, + displayKeys: ["⌥", "⌘", "D"], character: "d", modifiers: [.command, .option], + action: #selector(AppDelegate.triggerPickContrast), + notificationName: .triggerPickPair, + // Already bound globally as `KeyboardShortcuts.Name.pickPair`, which + // fires regardless of focus — skip local-monitor dispatch to avoid a + // double pick (mirrors the menu-accelerator omission in PikaApp.swift). + hasGlobalBinding: true + ), PikaShortcut( title: PikaText.textCopyForeground, displayKeys: ["⌘", "C"], character: "c", modifiers: .command, @@ -133,10 +166,12 @@ enum PikaShortcuts { ), ] - /// Returns the shortcut matching the given key event, if any. + /// Returns the shortcut matching the given key event, if any. Shortcuts that + /// are also globally bound are excluded — the global hotkey already dispatches + /// them regardless of focus, so matching here would double-fire the action. static func match(_ event: NSEvent) -> PikaShortcut? { let chars = event.charactersIgnoringModifiers?.lowercased() ?? "" let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return all.first { $0.character == chars && $0.modifiers == mods } + return all.first { $0.character == chars && $0.modifiers == mods && !$0.hasGlobalBinding } } } diff --git a/Pika/Constants/PikaText.swift b/Pika/Constants/PikaText.swift index 8a9e72d1..ce56e9d6 100644 --- a/Pika/Constants/PikaText.swift +++ b/Pika/Constants/PikaText.swift @@ -1,5 +1,6 @@ import Foundation +// swiftlint:disable:next type_body_length enum PikaText { static let textAppName = NSLocalizedString("app.name", comment: "Pika") @@ -16,6 +17,7 @@ enum PikaText { static let textColorForeground = NSLocalizedString("color.foreground", comment: "Foreground") static let textColorBackground = NSLocalizedString("color.background", comment: "Background") + static let textColorEditInvalid = NSLocalizedString("color.edit.invalid", comment: "Invalid input") static let textColorPass = NSLocalizedString("color.wcag.pass", comment: "Pass") static let textColorFail = NSLocalizedString("color.wcag.fail", comment: "Fail") static let textColorRatio = NSLocalizedString("color.ratio", comment: "Contrast Ratio") @@ -53,6 +55,9 @@ enum PikaText { static let textHelpSupportOnMAS = NSLocalizedString("help.mas", comment: "Support on the Mac App Store") static let textMenuAbout = NSLocalizedString("menu.about", comment: "About") + static let textMenuShowSplash = NSLocalizedString( + "menu.showSplash", value: "Show splash", comment: "Show the welcome splash" + ) static let textMenuUpdates = NSLocalizedString("menu.updates", comment: "Check for updates") static let textMenuPreferences = NSLocalizedString("menu.preferences", comment: "Preferences") static let textMenuWebsite = NSLocalizedString("menu.website", comment: "Pika website") @@ -79,6 +84,101 @@ enum PikaText { static let textSplashHotkey = NSLocalizedString("splash.launch", comment: "Launch at login") static let textSplashStart = NSLocalizedString("splash.start", comment: "Get started") + // Splash setup list (two-column redesign) + static let textSplashSetupTitle = NSLocalizedString( + "splash.setup.title", value: "Welcome to Pika", comment: "Splash setup heading" + ) + static let textSplashSetupSubtitle = NSLocalizedString( + "splash.setup.subtitle", value: "A few quick choices to get you started.", + comment: "Splash setup subheading" + ) + static let textSplashShortcutSubtitle = NSLocalizedString( + "splash.shortcut.subtitle", value: "Show and hide Pika from anywhere", + comment: "Global shortcut row subtitle" + ) + static let textSplashPairSubtitle = NSLocalizedString( + "splash.pair.subtitle", value: "Grab a foreground, then a background", + comment: "Pick-pair shortcut row subtitle" + ) + static let textSplashLaunchSubtitle = NSLocalizedString( + "splash.launch.subtitle", value: "Open Pika when you log in", + comment: "Launch at login row subtitle" + ) + static let textSplashPickerRecommended = NSLocalizedString( + "splash.picker.recommended", value: "Recommended", comment: "Custom picker recommended badge" + ) + static let textPickerRequiresScreenRecording = NSLocalizedString( + "picker.requiresScreenRecording", value: "Requires Screen Recording", + comment: "Pro picker capability note: needs the Screen Recording permission" + ) + static let textPickerGrantAccessibilityButton = NSLocalizedString( + "picker.grantAccessibility", value: "Grant Accessibility", + comment: "Button to grant the optional Accessibility permission for the Pro picker" + ) + static let textLoupeTheme = NSLocalizedString( + "loupe.theme", value: "Loupe style", comment: "Label for the Pro picker loupe theme setting" + ) + static let textLoupeThemeLens = NSLocalizedString( + "loupe.theme.lens", value: "Lens", comment: "Loupe theme: coloured rim with engraved text" + ) + static let textLoupeThemeBadge = NSLocalizedString( + "loupe.theme.badge", value: "Badge", comment: "Loupe theme: white badges on the inside edge" + ) + static let textLoupeThemeCard = NSLocalizedString( + "loupe.theme.card", value: "Card", comment: "Loupe theme: plain magnifier with a readout card beside it" + ) + static let textPickerPermissionsIntro = NSLocalizedString( + "picker.permissions.intro", value: "To enable the Pro picker, you’ll need to grant the following permissions:", + comment: "Intro line above the Pro picker permission buttons" + ) + static let textPickerPermScreenRecording = NSLocalizedString( + "picker.perm.screenRecording", value: "Screen Recording", + comment: "Permission pill label: Screen Recording" + ) + static let textPickerPermAccessibility = NSLocalizedString( + "picker.perm.accessibility", value: "Accessibility", + comment: "Permission pill label: Accessibility" + ) + static let textPickerAccessibilityNote = NSLocalizedString( + "picker.accessibilityNote", + value: "Optional — lets Escape and arrow-key nudging work while picking over other apps.", + comment: "Explains what the optional Accessibility permission adds to the Pro picker" + ) + static let textSplashPickerPermission = NSLocalizedString( + "splash.picker.permission", value: "Needs Screen Recording permission", + comment: "Custom picker permission note" + ) + static let textSplashPickerFallback = NSLocalizedString( + "splash.picker.fallback", value: "Not now? You’ll use the Basic picker.", + comment: "Pro picker declined fallback note" + ) + static let textSplashFooter = NSLocalizedString( + "splash.footer", value: "You can change all of this in Settings.", + comment: "Splash footer note" + ) + static let textSplashDontShowAgain = NSLocalizedString( + "splash.dontShowAgain", value: "Don’t show this again", + comment: "Splash: pre-selected checkbox to stop showing the splash on launch" + ) + static let textSplashConfirmTitle = NSLocalizedString( + "splash.confirm.title", value: "Use Pika’s Pro picker?", + comment: "Get started confirmation title when the Basic picker is selected" + ) + static let textSplashConfirmBody = NSLocalizedString( + "splash.confirm.body", + value: "The Pro picker shows the colour, its format, and live contrast right in the " + + "loupe as you pick — a much nicer experience than the macOS sampler. " + + "You can always switch later in Settings.", + comment: "Get started confirmation body" + ) + static let textSplashConfirmEnable = NSLocalizedString( + "splash.confirm.enable", value: "Enable Pro Picker", comment: "Confirmation: enable Pro picker" + ) + static let textSplashConfirmContinue = NSLocalizedString( + "splash.confirm.continue", value: "Continue with Basic Picker", + comment: "Confirmation: keep the Basic picker" + ) + /* * About */ @@ -192,6 +292,35 @@ enum PikaText { "preferences.names.description", comment: "Hide color names" ) + static let textColorListTitle = NSLocalizedString( + "preferences.colornames.title", value: "Colour Names", comment: "Colour name list section title" + ) + static let textColorListSubtitle = NSLocalizedString( + "preferences.colornames.subtitle", + value: "Choose the list Pika uses to name colours. Lists come from color.pizza and " + + "update automatically; the default works offline.", + comment: "Colour name list section subtitle" + ) + static let textColorListDefault = NSLocalizedString( + "preferences.colornames.default", value: "Default", comment: "Default colour list name" + ) + static let textColorListStatusChecking = NSLocalizedString( + "preferences.colornames.status.checking", value: "Checking color.pizza for updates…", + comment: "Colour list tooltip: a refresh is in progress" + ) + static let textColorListStatusUpdatedFormat = NSLocalizedString( + "preferences.colornames.status.updated", value: "Updated %@", + comment: "Colour list tooltip: when the list was last refreshed (%@ is a date)" + ) + static let textColorListStatusOffline = NSLocalizedString( + "preferences.colornames.status.offline", + value: "Couldn’t reach color.pizza. Using the saved colour list.", + comment: "Colour list tooltip: the last refresh failed, cached/bundled names are in use" + ) + static let textColorListStatusBuiltIn = NSLocalizedString( + "preferences.colornames.status.builtin", value: "Using the built-in colour list.", + comment: "Colour list tooltip: no network refresh has happened yet" + ) static let textFloatDescription = NSLocalizedString( "preferences.float.description", comment: "Float above windows" @@ -361,4 +490,70 @@ enum PikaText { "help.url.appearance.system", comment: "Restore system appearance" ) + + /* + * Custom colour picker + */ + + // Settings — picker style tiles + static let textPickerStyleTitle = NSLocalizedString( + "preferences.picker.title", value: "Colour Picker", comment: "Colour picker style section title" + ) + static let textPickerSystemTitle = NSLocalizedString( + "preferences.picker.system.title", value: "Basic picker", comment: "Basic picker tile title" + ) + static let textPickerSystemDescription = NSLocalizedString( + "preferences.picker.system.description", value: "macOS colour sampler", comment: "Basic picker tile subtitle" + ) + static let textPickerCustomTitle = NSLocalizedString( + "preferences.picker.custom.title", value: "Pro picker", comment: "Pro picker tile title" + ) + static let textPickerCustomDescription = NSLocalizedString( + "preferences.picker.custom.description", + value: "Live contrast & format", comment: "Pro picker tile subtitle" + ) + static let textPickerPairMode = NSLocalizedString( + "preferences.picker.pair", + value: "Pick a background straight after the foreground", comment: "Pair pick mode toggle" + ) + static let textPickerPermissionNeeded = NSLocalizedString( + "preferences.picker.permission", + value: "Pika needs Screen Recording permission to sample pixels for the Pro picker.", + comment: "Screen recording permission explanation" + ) + static let textPickerRelaunchNote = NSLocalizedString( + "preferences.picker.relaunch.note", + value: "Allow Screen Recording for Pika in System Settings, then relaunch Pika to finish enabling the Pro picker.", + comment: "Screen recording relaunch guidance" + ) + static let textPickerRelaunchButton = NSLocalizedString( + "preferences.picker.relaunch.button", value: "Relaunch Pika", + comment: "Relaunch Pika button" + ) + static let textPickerGrantButton = NSLocalizedString( + "preferences.picker.grant.button", value: "Grant Permission", + comment: "Grant Screen Recording permission button" + ) + + // Permission revoked alert + static let textPickerCustomRevertedTitle = NSLocalizedString( + "picker.reverted.title", value: "Switched to the Basic picker", + comment: "Permission revoked alert title" + ) + static let textPickerCustomRevertedBody = NSLocalizedString( + "picker.reverted.body", + value: "Pika’s Pro picker needs Screen Recording permission, which isn’t currently granted. " + + "You can re-enable it in Settings once permission is allowed.", + comment: "Permission revoked alert body" + ) + + // Splash picker choice + static let textSplashPickerPrompt = NSLocalizedString( + "splash.picker.prompt", value: "Colour picker", comment: "Splash picker choice label" + ) + + // Pick pair shortcut + static let textPickPair = NSLocalizedString( + "color.pick.pair", value: "Pick pair", comment: "Pick a foreground then background pair" + ) } diff --git a/Pika/Extensions/LatestAppStoreVersion+ShouldUpdate.swift b/Pika/Extensions/LatestAppStoreVersion+ShouldUpdate.swift index 329a1f40..c3bc2715 100644 --- a/Pika/Extensions/LatestAppStoreVersion+ShouldUpdate.swift +++ b/Pika/Extensions/LatestAppStoreVersion+ShouldUpdate.swift @@ -9,7 +9,7 @@ extension LatestAppStoreVersion { let versionString = "\(systemVersion.majorVersion).\(systemVersion.minorVersion).\(systemVersion.patchVersion)" let isRemoteVersionHigherThanLocal = currentVersion.compare(version, options: .numeric) == .orderedAscending - let isSystemVersionAllowed = versionString.compare(minimumOsVersion, options: .numeric) == .orderedDescending + let isSystemVersionAllowed = versionString.compare(minimumOsVersion, options: .numeric) != .orderedAscending return isRemoteVersionHigherThanLocal && isSystemVersionAllowed } diff --git a/Pika/Extensions/NSColor+HSL.swift b/Pika/Extensions/NSColor+HSL.swift index 6172c249..221a0568 100644 --- a/Pika/Extensions/NSColor+HSL.swift +++ b/Pika/Extensions/NSColor+HSL.swift @@ -138,6 +138,71 @@ extension NSColor { let hslString = NSString(format: formatString, hue, saturation, lightness) return hslString as String } + + /* + * Inverses (string/component → colour) + * + * These are the inverses of `toHSBComponents` / `toHSLComponents` and take the same + * normalised units (h, s, b/l all in 0…1). They build the colour directly in `colorSpace` + * — the same space the forward conversions read from — so a decompose→recompose round-trip + * lands on the same colour (±1 in the display units, from integer rounding). + */ + + /// Build a colour from HSB/HSV components (all in 0…1), in `colorSpace`. + static func fromHSB( + h: CGFloat, s: CGFloat, b: CGFloat, + in colorSpace: NSColorSpace = Defaults[.colorSpace] + ) -> NSColor { + let v = b + guard s > 0 else { + return NSColor(colorSpace: colorSpace, components: [v, v, v, 1], count: 4) + } + + let hue = (h.truncatingRemainder(dividingBy: 1) + 1).truncatingRemainder(dividingBy: 1) * 6 + let i = floor(hue) + let f = hue - i + let p = v * (1 - s) + let q = v * (1 - s * f) + let t = v * (1 - s * (1 - f)) + + let r, g, bl: CGFloat + switch Int(i) % 6 { + case 0: (r, g, bl) = (v, t, p) + case 1: (r, g, bl) = (q, v, p) + case 2: (r, g, bl) = (p, v, t) + case 3: (r, g, bl) = (p, q, v) + case 4: (r, g, bl) = (t, p, v) + default: (r, g, bl) = (v, p, q) + } + return NSColor(colorSpace: colorSpace, components: [r, g, bl, 1], count: 4) + } + + /// Build a colour from HSL components (all in 0…1), in `colorSpace`. + static func fromHSL( + h: CGFloat, s: CGFloat, l: CGFloat, + in colorSpace: NSColorSpace = Defaults[.colorSpace] + ) -> NSColor { + guard s > 0 else { + return NSColor(colorSpace: colorSpace, components: [l, l, l, 1], count: 4) + } + + func hue2rgb(_ p: CGFloat, _ q: CGFloat, _ t: CGFloat) -> CGFloat { + var t = t + if t < 0 { t += 1 } + if t > 1 { t -= 1 } + if t < 1 / 6 { return p + (q - p) * 6 * t } + if t < 1 / 2 { return q } + if t < 2 / 3 { return p + (q - p) * (2 / 3 - t) * 6 } + return p + } + + let q = l < 0.5 ? l * (1 + s) : l + s - l * s + let p = 2 * l - q + let r = hue2rgb(p, q, h + 1 / 3) + let g = hue2rgb(p, q, h) + let b = hue2rgb(p, q, h - 1 / 3) + return NSColor(colorSpace: colorSpace, components: [r, g, b, 1], count: 4) + } } // swiftlint:enable identifier_name diff --git a/Pika/Extensions/NSColor+Hex.swift b/Pika/Extensions/NSColor+Hex.swift index 782ca2b2..0745086d 100644 --- a/Pika/Extensions/NSColor+Hex.swift +++ b/Pika/Extensions/NSColor+Hex.swift @@ -1,14 +1,48 @@ import Cocoa +import Defaults extension NSColor { + /// Parse a hex string into a colour, or `nil` if it isn't valid hex. + /// + /// Accepts 3 or 6 hex digits, case-insensitive, with or without a leading `#`. + /// This is the one validating hex parser — unlike the `init(hex:)` convenience + /// init it never falls back to a colour, so callers (live editing, etc.) can tell + /// valid input from invalid. Builds in `colorSpace` so a value read back via + /// `toHexString()` (which reads `Defaults[.colorSpace]`) round-trips exactly. + static func fromHex( + _ hex: String, + in colorSpace: NSColorSpace = Defaults[.colorSpace], + alpha: CGFloat = 1 + ) -> NSColor? { + let trimmed = hex.trimmingCharacters(in: .whitespaces) + var stripped = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard stripped.count == 3 || stripped.count == 6, + stripped.allSatisfy(\.isHexDigit) else { return nil } + + if stripped.count == 3 { + stripped = stripped.map { "\($0)\($0)" }.joined() + } + + var rgb: UInt64 = 0 + guard Scanner(string: stripped).scanHexInt64(&rgb) else { return nil } + + let components: [CGFloat] = [ + CGFloat((rgb >> 16) & 0xFF) / 255, + CGFloat((rgb >> 8) & 0xFF) / 255, + CGFloat(rgb & 0xFF) / 255, + alpha, + ] + return NSColor(colorSpace: colorSpace, components: components, count: 4) + } + func roundToHex(_ value: CGFloat) -> UInt32 { guard value > 0 else { return 0 } let rounded: CGFloat = round(value * 255.0) return UInt32(rounded) } - func toHex() -> UInt32 { - let rgba = toRGBAComponents() + func toHex(in colorSpace: NSColorSpace = Defaults[.colorSpace]) -> UInt32 { + let rgba = toRGBAComponents(in: colorSpace) return roundToHex(rgba.r) << 16 | roundToHex(rgba.g) << 8 | roundToHex(rgba.b) } diff --git a/Pika/Extensions/NSColor+Init.swift b/Pika/Extensions/NSColor+Init.swift index 7122fade..7b53d959 100644 --- a/Pika/Extensions/NSColor+Init.swift +++ b/Pika/Extensions/NSColor+Init.swift @@ -16,29 +16,20 @@ extension NSColor { - parameter hex: The hex color, i.e. "FF0072" or "#FF0072". - parameter alpha: The opacity of the color, value between [0,1]. Optional. Default: 1 + + Invalid input falls back to black rather than crashing — this is fed remote and + user-facing strings (URL schemes, colour lists). Callers that need to *reject* + invalid input should use the failable `NSColor.fromHex(_:)` instead. */ convenience init(hex: String, alpha: CGFloat = 1) { - var hex = hex.replacingOccurrences(of: "#", with: "") - - guard hex.count == 3 || hex.count == 6 else { - fatalError("Hex characters must be either 3 or 6 characters.") - } - - if hex.count == 3 { - let tmp = hex - hex = "" - for char in tmp { - hex += String([char, char]) - } - } - - let scanner = Scanner(string: hex) - var rgb: UInt64 = 0 - scanner.scanHexInt64(&rgb) - - let red = CGFloat((rgb >> 16) & 0xFF) / 255 - let green = CGFloat((rgb >> 8) & 0xFF) / 255 - let blue = CGFloat(rgb & 0xFF) / 255 - self.init(red: red, green: green, blue: blue, alpha: alpha) + let parsed = NSColor.fromHex(hex, in: .sRGB, alpha: alpha) + ?? NSColor(colorSpace: .sRGB, components: [0, 0, 0, alpha], count: 4) + // `parsed` is already in sRGB, so its components read back without conversion. + self.init( + srgbRed: parsed.redComponent, + green: parsed.greenComponent, + blue: parsed.blueComponent, + alpha: parsed.alphaComponent + ) } } diff --git a/Pika/Extensions/NSColor+Lab.swift b/Pika/Extensions/NSColor+Lab.swift index 0d8802e3..d8e36ec1 100644 --- a/Pika/Extensions/NSColor+Lab.swift +++ b/Pika/Extensions/NSColor+Lab.swift @@ -14,6 +14,15 @@ extension NSColor { c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) } + // Inverse of `linearizeSRGB`: gamma-encode a linear-light component, clamped to [0, 1]. + // Lab and OKLCH can express colours outside the sRGB gamut, so the clamp snaps those + // to the nearest displayable channel value (see the round-trip note in the plan). + private static func encodeSRGB(_ c: CGFloat) -> CGFloat { + let clamped = Swift.min(Swift.max(c, 0), 1) + let encoded = clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * pow(clamped, 1 / 2.4) - 0.055 + return Swift.min(Swift.max(encoded, 0), 1) + } + /* * OpenGL */ @@ -141,6 +150,75 @@ extension NSColor { return "\(l_str), \(c_str), \(h_str)" } } + + /* + * Inverses (component → colour) + * + * Inverses of `toLabComponents` / `toOklchComponents`, taking the same units the forward + * conversions produce (Lab: L 0…100, a/b unbounded; OKLCH: L 0…1, C ≥ 0, H in degrees). + * Both forward conversions are defined via the sRGB path, so these build in sRGB. Out-of-gamut + * inputs are clamped per channel (see `encodeSRGB`), so a round-trip on an out-of-gamut colour + * snaps to the nearest displayable one — inherent to storing sRGB. + */ + + /// Build an sRGB colour from CIE-Lab components (L in 0…100, a/b in Lab units). + static func fromLab(l: CGFloat, a: CGFloat, b: CGFloat) -> NSColor { + // Lab → XYZ (D65 reference white), inverse of the forward f(). + let Xn: CGFloat = 0.95047 + let Yn: CGFloat = 1.00000 + let Zn: CGFloat = 1.08883 + let delta: CGFloat = 6.0 / 29.0 + + func fInv(_ t: CGFloat) -> CGFloat { + t > delta ? pow(t, 3) : 3 * pow(delta, 2) * (t - 4.0 / 29.0) + } + + let fy = (l + 16) / 116 + let fx = fy + a / 500 + let fz = fy - b / 200 + + let x = Xn * fInv(fx) + let y = Yn * fInv(fy) + let z = Zn * fInv(fz) + + // XYZ → linear sRGB + let r_lin = x * 3.2404542 - y * 1.5371385 - z * 0.4985314 + let g_lin = x * -0.9692660 + y * 1.8760108 + z * 0.0415560 + let b_lin = x * 0.0556434 - y * 0.2040259 + z * 1.0572252 + + return NSColor( + colorSpace: .sRGB, + components: [encodeSRGB(r_lin), encodeSRGB(g_lin), encodeSRGB(b_lin), 1], + count: 4 + ) + } + + /// Build an sRGB colour from OKLCH components (L in 0…1, C ≥ 0, H in degrees). + static func fromOklch(l: CGFloat, c: CGFloat, h: CGFloat) -> NSColor { + let hRad = h * .pi / 180 + let a = c * cos(hRad) + let b = c * sin(hRad) + + // OKLCH → OKLab → LMS' → LMS (inverse of the forward M2/M1 matrices). + 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 + + let lCube = l_ * l_ * l_ + let mCube = m_ * m_ * m_ + let sCube = s_ * s_ * s_ + + // LMS → linear sRGB + let r_lin = lCube * 4.0767416621 - mCube * 3.3077115913 + sCube * 0.2309699292 + let g_lin = lCube * -1.2684380046 + mCube * 2.6097574011 - sCube * 0.3413193965 + let b_lin = lCube * -0.0041960863 - mCube * 0.7034186147 + sCube * 1.7076147010 + + return NSColor( + colorSpace: .sRGB, + components: [encodeSRGB(r_lin), encodeSRGB(g_lin), encodeSRGB(b_lin), 1], + count: 4 + ) + } } // swiftlint:enable identifier_name diff --git a/Pika/Extensions/NSColor+RGB.swift b/Pika/Extensions/NSColor+RGB.swift index 7412d681..a58f6aea 100644 --- a/Pika/Extensions/NSColor+RGB.swift +++ b/Pika/Extensions/NSColor+RGB.swift @@ -64,12 +64,17 @@ extension NSColor { } } + // Black or white — whichever has the higher WCAG contrast against this colour — for legible + // text/UI drawn on top of it. The crossover (equal contrast to black and white) is at a + // relative luminance of ~0.179, not 0.5: black wins for everything brighter than that. + private static let uiColorCrossover: CGFloat = 0.179 + func getUIColor() -> Color { - luminance < 0.5 ? Color.white : Color.black + luminance < Self.uiColorCrossover ? Color.white : Color.black } func getUIColor() -> NSColor { - luminance < 0.5 ? NSColor.white : NSColor.black + luminance < Self.uiColorCrossover ? NSColor.white : NSColor.black } } diff --git a/Pika/Pika.entitlements b/Pika/Pika.entitlements index 852fa1a4..ee95ab7e 100644 --- a/Pika/Pika.entitlements +++ b/Pika/Pika.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.network.client + diff --git a/Pika/PikaApp.swift b/Pika/PikaApp.swift index 453d01f7..f6d032d6 100644 --- a/Pika/PikaApp.swift +++ b/Pika/PikaApp.swift @@ -8,6 +8,7 @@ private struct PikaCommands: Commands { var body: some Commands { // Pika menu items added before the first divider after .appInfo (About Pika). CommandGroup(after: .appInfo) { + Button(PikaText.textMenuShowSplash) { send(#selector(AppDelegate.openSplashWindow)) } Button(PikaText.textMenuUpdates) { send(#selector(AppDelegate.checkForUpdates)) } Button(PikaText.textMenuPreferences) { send(#selector(AppDelegate.openPreferencesWindow)) } .keyboardShortcut(",", modifiers: .command) @@ -16,12 +17,30 @@ private struct PikaCommands: Commands { // Replace the SwiftUI default Settings command (we handle Preferences ourselves). CommandGroup(replacing: .appSettings) {} + // Replace the default Undo/Redo group: its stock items send the plain `undo:`/`redo:` + // responder-chain actions, which AppKit's automatic menu validation disables unless some + // responder in the chain vends a real `NSUndoManager` — nothing here does, so those items + // stayed permanently greyed out and Cmd-Z silently did nothing. Explicit actions bound to + // `triggerUndo`/`triggerRedo` sidestep that validation entirely (same pattern as every + // other custom menu item below); those methods do their own focus-scoping — deferring to + // a focused field's own undo manager before falling back to colour-pick history. + CommandGroup(replacing: .undoRedo) { + Button(PikaText.textColorUndo) { send(#selector(AppDelegate.triggerUndo)) } + .keyboardShortcut("z", modifiers: .command) + Button(PikaText.textColorRedo) { send(#selector(AppDelegate.triggerRedo)) } + .keyboardShortcut("z", modifiers: [.command, .shift]) + } + // Pasteboard group hosts the picker / copy / format actions. CommandGroup(replacing: .pasteboard) { Button(PikaText.textPickForeground + "…") { send(#selector(AppDelegate.triggerPickForeground)) } .keyboardShortcut("d", modifiers: .command) Button(PikaText.textPickBackground + "…") { send(#selector(AppDelegate.triggerPickBackground)) } .keyboardShortcut("d", modifiers: [.command, .shift]) + // No key equivalent here: pick-pair is now a global, rebindable shortcut + // (KeyboardShortcuts.Name.pickPair). A menu accelerator would double-fire + // with the global hotkey and wouldn't track user rebindings. + Button(PikaText.textPickPair + "…") { send(#selector(AppDelegate.triggerPickContrast)) } Divider() diff --git a/Pika/Services/ClosestVector.swift b/Pika/Services/ClosestVector.swift index b10394f6..473463df 100644 --- a/Pika/Services/ClosestVector.swift +++ b/Pika/Services/ClosestVector.swift @@ -16,7 +16,10 @@ public class ClosestVector { public func compare(_ val: NSColor) -> (Int) { guard let color = val.usingColorSpace(.sRGB) else { return 0 } - let colorArr = [Int(color.redComponent * 255), Int(color.greenComponent * 255), Int(color.blueComponent * 255)] + // Quantise with the same rounded helper used to build the named-color + // database (see Eyedropper + toRGB8BitArray), so the query and the + // database agree on every 8-bit bucket instead of truncate-vs-round. + let colorArr = color.toRGB8BitArray() var minDistance = Int.max var index = 0 diff --git a/Pika/Services/ColorDecomposition.swift b/Pika/Services/ColorDecomposition.swift new file mode 100644 index 00000000..8386cb70 --- /dev/null +++ b/Pika/Services/ColorDecomposition.swift @@ -0,0 +1,323 @@ +import Cocoa +import Defaults + +// swiftlint:disable identifier_name +// identifier_name is disabled because colour-component math uses conventional single-letter +// names (r, g, b, h, s, l, a) that would be misleading if renamed. + +/// How a single editable component is validated and parsed. +enum ComponentKind: Equatable { + case hex // 3 or 6 hex digits + case integer // whole number in `range` + case decimal // decimal number in `range` (nil range = unbounded, e.g. Lab a/b) +} + +/// One editable number (or hex string) inside a formatted colour value. +struct ColorComponent: Equatable { + var value: String + var kind: ComponentKind + var range: ClosedRange? + + /// Whether `candidate` is a valid value for this component's kind and range. + /// This is what the editable field uses to drive its valid/invalid state. + func isValid(_ candidate: String) -> Bool { + let trimmed = candidate.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return false } + switch kind { + case .hex: + return NSColor.fromHex(trimmed) != nil + case .integer: + // Reject decimals/exponents for integer fields; only a plain whole number. A + // number outside `range` is still valid input, not rejected — it's clamped to the + // nearest bound on commit (see `EditableColorValue.finalizeValues`), matching + // `recompose`'s own clamping rather than reverting the whole edit. + return Int(trimmed) != nil + case .decimal: + // `Double("inf")`/`Double("nan")` parse successfully but aren't valid colour + // values, and a nil range (e.g. Lab a/b) wouldn't otherwise reject them. As with + // `.integer`, a finite out-of-range number is valid input — clamped on commit. + guard let n = Double(trimmed) else { return false } + return n.isFinite + } + } +} + +/// A formatted colour value split into fixed scaffolding + editable components. +/// +/// Invariant: `joined()` equals `color.toFormat(format:style:)` for the colour it was +/// decomposed from — the editable readout renders the same text as the read-only one. +struct DecomposedColor: Equatable { + var leading: String + var components: [ColorComponent] + var separators: [String] // between components; count == components.count - 1 + var trailing: String + + var values: [String] { components.map(\.value) } + + func joined() -> String { + var result = leading + for (i, comp) in components.enumerated() { + result += comp.value + if i < separators.count { result += separators[i] } + } + return result + trailing + } +} + +extension ColorFormat { + /// Split a colour into fixed scaffolding + editable components for `(self, style)`. + /// Mirrors the matching `to…String` formatter exactly (see `joined()` invariant). + // swiftlint:disable:next cyclomatic_complexity function_body_length + func decompose(_ color: NSColor, style: CopyFormat, in cs: NSColorSpace) -> DecomposedColor { + switch self { + case .hex: + let digits = String(format: "%06x", color.toHex(in: cs)) + return DecomposedColor( + leading: style == .css ? "#" : "", + components: [ColorComponent(value: digits, kind: .hex, range: nil)], + separators: [], + trailing: "" + ) + + case .rgb: + let rgb = color.toRGBAComponents(in: cs) + switch style { + case .swiftUI: + return DecomposedColor( + leading: "Color(red: ", + components: floatComponents([rgb.r, rgb.g, rgb.b], range: 0 ... 1), + separators: [", green: ", ", blue: "], + trailing: ")" + ) + default: + let comps = int255Components([rgb.r, rgb.g, rgb.b]) + return DecomposedColor( + leading: style == .unformatted ? "" : "rgb(", + components: comps, + separators: [", ", ", "], + trailing: style == .unformatted ? "" : ")" + ) + } + + case .opengl: + let rgb = color.toRGBAComponents(in: cs) + let comps = [rgb.r, rgb.g, rgb.b].map { + ColorComponent(value: openGLValueString($0), kind: .decimal, range: 0 ... 1) + } + return DecomposedColor( + leading: style == .unformatted ? "" : "rgba(", + components: comps, + separators: [", ", ", "], + trailing: style == .unformatted ? ", 1.0" : ", 1.0)" + ) + + case .hsb: + let hsb = color.toHSBComponents() + switch style { + case .swiftUI: + return DecomposedColor( + leading: "Color(hue: ", + components: floatComponents([hsb.h, hsb.s, hsb.b], range: 0 ... 1), + separators: [", saturation: ", ", brightness: "], + trailing: ")" + ) + case .css: + return DecomposedColor( + leading: "hsb(", + components: angleAndPercents(h: hsb.h, [hsb.s, hsb.b]), + separators: [", ", "%, "], + trailing: "%)" + ) + default: + return DecomposedColor( + leading: style == .unformatted ? "" : "hsb(", + components: angleAndPercents(h: hsb.h, [hsb.s, hsb.b]), + separators: [", ", ", "], + trailing: style == .unformatted ? "" : ")" + ) + } + + case .hsl: + let hsl = color.toHSLComponents() + let comps = angleAndPercents(h: hsl.h, [hsl.s, hsl.l]) + switch style { + case .css: + return DecomposedColor( + leading: "hsl(", components: comps, separators: [", ", "%, "], trailing: "%)" + ) + case .unformatted: + return DecomposedColor( + leading: "", components: comps, separators: [", ", ", "], trailing: "" + ) + default: // design, swiftUI both render "hsl(%d, %d, %d)" + return DecomposedColor( + leading: "hsl(", components: comps, separators: [", ", ", "], trailing: ")" + ) + } + + case .lab: + let lab = color.toLabComponents() + let comps = [ + labComponent(lab.l, range: 0 ... 100), + labComponent(lab.a, range: nil), + labComponent(lab.b, range: nil), + ] + switch style { + case .css: + return DecomposedColor( + leading: "lab(", components: comps, separators: [" ", " "], trailing: ")" + ) + case .unformatted: + return DecomposedColor( + leading: "", components: comps, separators: [", ", ", "], trailing: "" + ) + default: + return DecomposedColor( + leading: "lab(", components: comps, separators: [", ", ", "], trailing: ")" + ) + } + + case .oklch: + let oklch = color.toOklchComponents() + // L is shown as a 0–100 percentage-magnitude in every style; only css appends "%". + let comps = [ + ColorComponent( + value: (round(oklch.l * 10000) / 100).strippedDecimalString(maxDecimalPlaces: 2), + kind: .decimal, range: 0 ... 100 + ), + ColorComponent( + value: (round(oklch.c * 10000) / 10000).strippedDecimalString(maxDecimalPlaces: 4), + kind: .decimal, range: 0 ... 1 + ), + ColorComponent( + value: (round(oklch.h * 100) / 100).strippedDecimalString(maxDecimalPlaces: 2), + kind: .decimal, range: 0 ... 360 + ), + ] + switch style { + case .css: + return DecomposedColor( + leading: "oklch(", components: comps, separators: ["% ", " "], trailing: ")" + ) + case .unformatted: + return DecomposedColor( + leading: "", components: comps, separators: [", ", ", "], trailing: "" + ) + default: + return DecomposedColor( + leading: "oklch(", components: comps, separators: [", ", ", "], trailing: ")" + ) + } + } + } + + /// Rebuild an sRGB-normalisable colour from edited component strings, or `nil` if any is + /// unparseable. Assumes per-component validity has already been checked by the field; still + /// clamps into range so a committed colour is always displayable. + func recompose(_ values: [String], style: CopyFormat, in cs: NSColorSpace) -> NSColor? { + switch self { + case .hex: + guard values.count == 1 else { return nil } + return NSColor.fromHex(values[0], in: cs) + + case .rgb: + guard let triple = doubles(values, count: 3) else { return nil } + let rgb: [CGFloat] + if style == .swiftUI { + rgb = triple.map { clamp($0, 0, 1) } + } else { + rgb = triple.map { clamp($0, 0, 255) / 255 } + } + return NSColor(colorSpace: cs, components: rgb + [1], count: 4) + + case .opengl: + guard let triple = doubles(values, count: 3) else { return nil } + let rgb = triple.map { clamp($0, 0, 1) } + return NSColor(colorSpace: cs, components: rgb + [1], count: 4) + + case .hsb: + guard let triple = doubles(values, count: 3) else { return nil } + let (h, s, b): (CGFloat, CGFloat, CGFloat) + if style == .swiftUI { + (h, s, b) = (clamp(triple[0], 0, 1), clamp(triple[1], 0, 1), clamp(triple[2], 0, 1)) + } else { + (h, s, b) = (clamp(triple[0], 0, 360) / 360, clamp(triple[1], 0, 100) / 100, clamp(triple[2], 0, 100) / 100) + } + return NSColor.fromHSB(h: h, s: s, b: b, in: cs) + + case .hsl: + guard let triple = doubles(values, count: 3) else { return nil } + let h = clamp(triple[0], 0, 360) / 360 + let s = clamp(triple[1], 0, 100) / 100 + let l = clamp(triple[2], 0, 100) / 100 + return NSColor.fromHSL(h: h, s: s, l: l, in: cs) + + case .lab: + guard let triple = doubles(values, count: 3) else { return nil } + // Only `l` has a range (0...100, matching `decompose`'s `labComponent`); `a`/`b` + // are intentionally unbounded, so nothing to clamp for them. + return NSColor.fromLab(l: clamp(triple[0], 0, 100), a: triple[1], b: triple[2]) + + case .oklch: + guard let triple = doubles(values, count: 3) else { return nil } + // Clamp all three to the same ranges `decompose` declares (l: 0...100, c: 0...1, + // h: 0...360) before computing — `fromOklch` derives RGB via `cos`/`sin` on `h` and + // has no clamping of its own, so an out-of-range value here silently computes a + // *different* colour than the one `EditableColorValue.finalizeValues` displays + // after snapping the typed text to those same bounds (e.g. h=400° would otherwise + // wrap to 40° instead of matching the clamped, displayed 360°/red). + let l = clamp(triple[0], 0, 100) / 100 + let c = clamp(triple[1], 0, 1) + let h = clamp(triple[2], 0, 360) + return NSColor.fromOklch(l: l, c: c, h: h) + } + } +} + +// MARK: - Private formatting/parsing helpers + +private func int255Components(_ channels: [CGFloat]) -> [ColorComponent] { + channels.map { ColorComponent(value: String(Int(round($0 * 255))), kind: .integer, range: 0 ... 255) } +} + +private func floatComponents(_ channels: [CGFloat], range: ClosedRange) -> [ColorComponent] { + channels.map { ColorComponent(value: String(format: "%.5g", $0), kind: .decimal, range: range) } +} + +/// Hue (0–360 integer) followed by two 0–100 integer percentages (S/B or S/L). +private func angleAndPercents(h: CGFloat, _ rest: [CGFloat]) -> [ColorComponent] { + let hue = ColorComponent(value: String(Int(round(h * 360))), kind: .integer, range: 0 ... 360) + let percents = rest.map { + ColorComponent(value: String(Int(round($0 * 100))), kind: .integer, range: 0 ... 100) + } + return [hue] + percents +} + +/// Matches `toOpenGLString`'s ".0"-appended `%.5g`, e.g. 0 → "0.0", 1 → "1.0", 0.5 → "0.5". +private func openGLValueString(_ value: CGFloat) -> String { + let s = String(format: "%.5g", value) + return s.contains(".") ? s : "\(s).0" +} + +private func labComponent(_ value: CGFloat, range: ClosedRange?) -> ColorComponent { + ColorComponent( + value: (round(value * 100) / 100).strippedDecimalString(maxDecimalPlaces: 2), + kind: .decimal, range: range + ) +} + +private func doubles(_ values: [String], count: Int) -> [CGFloat]? { + guard values.count == count else { return nil } + var out: [CGFloat] = [] + for v in values { + guard let d = Double(v.trimmingCharacters(in: .whitespaces)) else { return nil } + out.append(CGFloat(d)) + } + return out +} + +private func clamp(_ value: CGFloat, _ lower: CGFloat, _ upper: CGFloat) -> CGFloat { + Swift.min(Swift.max(value, lower), upper) +} + +// swiftlint:enable identifier_name diff --git a/Pika/Services/ColorPair.swift b/Pika/Services/ColorPair.swift index f824af9b..3a6ff72e 100644 --- a/Pika/Services/ColorPair.swift +++ b/Pika/Services/ColorPair.swift @@ -12,21 +12,12 @@ struct ColorPair: Codable, Identifiable, Equatable { // Reconstructs the color in Defaults[.colorSpace] — the same space toHexString() // reads from. This makes set() a no-op (colorSpace → colorSpace) so the stored - // hex round-trips exactly. + // hex round-trips exactly. Stored hex is always 6 digits (from toHexString), so + // anything else is treated as corrupt and falls back to black. private static func colorFromHex(_ hex: String) -> NSColor { let fallback = NSColor.black.usingColorSpace(Defaults[.colorSpace]) ?? .black - let stripped = hex.replacingOccurrences(of: "#", with: "") - guard stripped.count == 6 else { return fallback } - let scanner = Scanner(string: stripped) - var rgb: UInt64 = 0 - guard scanner.scanHexInt64(&rgb) else { return fallback } - let components: [CGFloat] = [ - CGFloat((rgb >> 16) & 0xFF) / 255, - CGFloat((rgb >> 8) & 0xFF) / 255, - CGFloat(rgb & 0xFF) / 255, - 1.0, - ] - return NSColor(colorSpace: Defaults[.colorSpace], components: components, count: 4) + guard hex.replacingOccurrences(of: "#", with: "").count == 6 else { return fallback } + return NSColor.fromHex(hex) ?? fallback } static let maxHistory = 20 diff --git a/Pika/Services/ColorPickSession.swift b/Pika/Services/ColorPickSession.swift new file mode 100644 index 00000000..7659f878 --- /dev/null +++ b/Pika/Services/ColorPickSession.swift @@ -0,0 +1,50 @@ +import AppKit + +/// Abstracts the "pick a colour for this target" step so the eyedropper commit +/// path (set / history / undo / overlay / chaining) can stay identical across the +/// system sampler and the custom loupe. Only the picking UI differs — the caller +/// handles everything after a colour comes back. +/// +/// See `plans/ready/2026-07-19-custom-color-picker.md`. +protocol ColorPickSession: AnyObject { + /// Begins a single pick for `target`. Calls `completion` with the chosen + /// `NSColor`, or `nil` if the pick was cancelled. + /// + /// - Parameters: + /// - target: which slot is being picked (drives the loupe's slot indicator). + /// - comparison: the already-picked colour in the *other* slot, used by the + /// custom loupe for live contrast. The system sampler ignores it. + /// - willChain: `true` when a successful pick will immediately hand off to a + /// second (background) pick — the custom loupe uses this to stay visible + /// across the pair instead of tearing down between picks. Ignored by the + /// system sampler. + /// - completion: called once with the picked colour or `nil`. + func begin( + target: Eyedropper.Types, + comparison: NSColor?, + willChain: Bool, + completion: @escaping (NSColor?) -> Void + ) + + /// Cancels an in-flight pick and tears down any picking UI. + func cancel() +} + +/// Wraps `NSColorSampler` — the default picker and the guaranteed fallback. With +/// `Defaults[.pickerStyle] == .system` this is byte-for-byte today's behaviour. +final class SystemColorPickSession: ColorPickSession { + private let sampler = NSColorSampler() + + func begin( + target _: Eyedropper.Types, + comparison _: NSColor?, + willChain _: Bool, + completion: @escaping (NSColor?) -> Void + ) { + sampler.show { completion($0) } + } + + func cancel() { + // NSColorSampler owns its own lifecycle and exposes no cancel API. + } +} diff --git a/Pika/Services/CustomColorPickSession.swift b/Pika/Services/CustomColorPickSession.swift new file mode 100644 index 00000000..eff0795e --- /dev/null +++ b/Pika/Services/CustomColorPickSession.swift @@ -0,0 +1,749 @@ +import AppKit +import ApplicationServices +import Defaults +import ScreenCaptureKit +import SwiftUI + +/// The Pika-native loupe picker. Conforms to `ColorPickSession` and forwards to the +/// shared `PickerLoupeController`, which owns the capture engine, event monitors and +/// floating panel. Keeping that state in a singleton lets the loupe stay visible +/// across a foreground → background pair pick instead of tearing down between shots. +/// +/// See `plans/ready/2026-07-19-custom-color-picker.md`. +final class CustomColorPickSession: ColorPickSession { + func begin( + target: Eyedropper.Types, + comparison: NSColor?, + willChain: Bool, + completion: @escaping (NSColor?) -> Void + ) { + PickerLoupeController.shared.begin( + target: target, + comparison: comparison, + willChain: willChain, + completion: completion + ) + } + + func cancel() { + PickerLoupeController.shared.cancel() + } + + // MARK: - Permission + + /// The custom picker is only usable once Screen Recording (TCC) is granted. + static var isAvailable: Bool { + CGPreflightScreenCaptureAccess() + } + + /// Requests Screen Recording permission, calling back on the main thread with the + /// result. Used by the enabling flow (Settings / splash). `CGRequestScreenCaptureAccess` + /// blocks until the user responds the first time, so it runs off the main thread. + /// + /// Note: a first-time grant only takes effect after the app relaunches, so this + /// typically calls back `false` even when the user goes on to allow it in System + /// Settings — the caller surfaces relaunch guidance in that case. + static func requestAccess(_ completion: @escaping (Bool) -> Void) { + if CGPreflightScreenCaptureAccess() { + completion(true) + return + } + // The system permission dialog appears above the requesting window: even + // when Pika's secondary windows ride `.floating` (see `createSecondaryWindow`), + // system dialogs sit above that level. + DispatchQueue.global(qos: .userInitiated).async { + let granted = CGRequestScreenCaptureAccess() + DispatchQueue.main.async { completion(granted) } + } + } + + /// Relaunches Pika so a newly granted Screen Recording permission takes effect (the + /// running process caches the pre-grant state until it restarts). Sandbox-safe: asks + /// the workspace to spawn a fresh instance, then terminates this one. + static func relaunch() { + let configuration = NSWorkspace.OpenConfiguration() + configuration.createsNewApplicationInstance = true + NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: configuration) { _, _ in + DispatchQueue.main.async { NSApp.terminate(nil) } + } + } + + private static var didNotePermissionRevert = false + + /// Explains once, per launch, that the picker fell back to the system sampler + /// because Screen Recording permission was revoked. + static func notePermissionRevertedOnce() { + guard !didNotePermissionRevert else { return } + didNotePermissionRevert = true + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = PikaText.textPickerCustomRevertedTitle + alert.informativeText = PikaText.textPickerCustomRevertedBody + alert.alertStyle = .informational + alert.runModal() + } + } +} + +/// Shared owner of the loupe UI, ScreenCaptureKit capture loop, and the full-screen +/// catcher that drives cursor tracking and swallows the committing click. +// swiftlint:disable:next type_body_length +final class PickerLoupeController { + static let shared = PickerLoupeController() + private init() {} + + let viewModel = LoupeViewModel() + + // The loupe is two stacked panels: the lens (a circular magnifier with the readouts + // engraved around its rim) centred on the cursor, and a full-screen catcher beneath it + // that swallows the committing click so it never reaches the desktop. + private var circlePanel: LoupeCirclePanel? + private var catcher: LoupeClickCatcherPanel? + // Only shown for the `.card` theme: the readout card tucked beside the magnifier. + private var cardPanel: LoupeCardPanel? + private var completion: ((NSColor?) -> Void)? + private var willChain = false + + // Keep-alive across a pair pick: after the foreground commit we expect the caller + // to re-arm for the background almost immediately, so we don't tear the panel down. + private var rearmSafety: Timer? + + // Event capture. Preferred: a session `CGEventTap` that intercepts (and swallows) clicks, + // scroll and keys globally — the only reliable way to catch input over *other* apps, which + // needs Accessibility. Fallback (no Accessibility): `NSEvent` monitors, which only see input + // dispatched to Pika (so they work over Pika's own window but not over other apps). + private var eventTap: CFMachPort? + private var eventTapSource: CFRunLoopSource? + private var localMonitors: [Any] = [] + + // True while the loupe is up (shown, not torn down). A pair pick keeps this set across + // the foreground → background handoff; a cancel/commit teardown clears it. `begin` uses + // it to tell a re-arm (reuse the visible loupe) from a fresh pick (build it again) — + // panels are held for reuse and stay non-nil after teardown, so their presence alone + // can't distinguish the two. + private var isActive = false + + // Set when we activated Pika (Accessibility not granted) to receive keys during a pick; + // its value is the app to restore focus to on teardown. + private var appToRestore: NSRunningApplication? + + // Tracks whether we've hidden the system cursor for the pick, so hide/show stay balanced. + private var cursorHidden = false + + // Closest-colour-name lookup for the lens theme, built once per pick from the active list. + private var colorNames: [ColorName] = [] + private var closestVector: ClosestVector? + + // Live preview: while picking, the sampled colour is pushed into the target eyedropper so + // the main window (swatches, colour names, and the contrast footer) updates in realtime. + // `previewOriginal` is the target's colour at the start of the pick, restored on cancel. + // History is gated on the `.colorPicked` notification (posted only at commit), so these + // live writes never record undo steps. + private var previewOriginal: NSColor? + // Bumped whenever a pick begins or ends. `performCapture` is async, so a grab can resume + // after the pick it belongs to was cancelled/committed; it applies its result only if the + // generation still matches, so a straggler can't write a colour into a finished pick. + private var pickGeneration = 0 + + // Capture state. + private var configuredDisplayID: CGDirectDisplayID? + private var baseFilter: SCContentFilter? + private var loupeWindowIDs: [CGWindowID] = [] + private var isCapturing = false + private var pendingCapture = false + private var currentCursor: NSPoint = .zero + + // MARK: - Session lifecycle + + func begin( + target: Eyedropper.Types, + comparison: NSColor?, + willChain: Bool, + completion: @escaping (NSColor?) -> Void + ) { + pickGeneration += 1 + self.completion = completion + self.willChain = willChain + rearmSafety?.invalidate() + rearmSafety = nil + + viewModel.target = target + viewModel.comparison = comparison + viewModel.comparisonName = comparison.map { closestColorName(for: $0) } ?? "" + // Snapshot the target's colour so a cancelled pick can restore it (we mutate it live + // for the realtime preview below). + previewOriginal = targetEyedropper?.color + currentCursor = NSEvent.mouseLocation + // Set the over-app state (dismiss disc vs picker) BEFORE the loupe is shown, without + // animation — otherwise a reused panel starts in the picker state and visibly flashes + // the loupe before animating to the dismiss disc when the pick begins over Pika. + var noAnimation = Transaction() + noAnimation.disablesAnimations = true + withTransaction(noAnimation) { viewModel.isOverApp = isCursorOverAppWindow() } + + // Pair-pick re-arm: the loupe is already up, so just refresh it. + if isActive { + reposition() + requestCapture() + return + } + + // Fresh pick: capture one frame *before* showing the loupe, so it appears already + // showing the live sample — no flash of the previous pick's colour, no black frame, + // and on a launch's first pick the macOS consent prompt appears ahead of the loupe. + isCapturing = true + Task { @MainActor in + await self.performCapture() + self.isCapturing = false + // The pick may have been cancelled while the consent prompt was up. + guard self.completion != nil else { self.teardown(); return } + self.showPanel() + self.installMonitors() + // That frame was captured before the loupe existed; rebuild the filter so the + // loupe windows are excluded from subsequent frames. + self.configuredDisplayID = nil + self.reposition() + self.requestCapture() + } + } + + func cancel() { + finish(with: nil) + } + + // MARK: - Panel + + private func showPanel() { + if circlePanel == nil { circlePanel = LoupeCirclePanel(viewModel: viewModel) } + if cardPanel == nil { cardPanel = LoupeCardPanel(viewModel: viewModel) } + if catcher == nil { + let catcher = LoupeClickCatcherPanel() + catcher.onCommit = { [weak self] in self?.commit() } + catcher.onCancel = { [weak self] in self?.cancel() } + catcher.onMoved = { [weak self] in self?.handlePointerMoved() } + catcher.onScroll = { [weak self] event in self?.handleScroll(deltaY: Double(event.deltaY)) } + self.catcher = catcher + } + + // The catcher must be the front-most surface so it swallows clicks/scroll; it's ordered + // last (after the circle and card) and re-fronted on every cursor move (see + // `updateCardPanel`). Being transparent, it doesn't hide the lens. It also takes key + // status so Escape / zoom / nudge reach us without activating Pika. + catcher?.cover(screens: NSScreen.screens) + circlePanel?.orderFrontRegardless() + updateCardPanel() + catcher?.orderFrontRegardless() + + loupeWindowIDs = [circlePanel?.windowNumber, cardPanel?.windowNumber, catcher?.windowNumber] + .compactMap { $0 } + .map { CGWindowID($0) } + + // Activate (fallback) before taking key status so the catcher stays key afterwards. + activateForKeysIfNeeded() + catcher?.makeKey() + + // The loupe circle sits on the cursor, so hide the system cursor for the pick. + if !cursorHidden { + CGDisplayHideCursor(CGMainDisplayID()) + cursorHidden = true + } + + isActive = true + } + + /// A non-activating panel only receives keys while Pika is frontmost. If Pika is trusted + /// for Accessibility the global key monitor covers Escape/zoom/nudge without stealing + /// focus; otherwise fall back to briefly activating Pika (restoring focus on teardown) so + /// the local key monitor works — the pick already covers the screen with the catcher, so + /// the sampled app going inactive for the pick's duration is acceptable. + private func activateForKeysIfNeeded() { + guard appToRestore == nil, !AXIsProcessTrusted() else { return } + appToRestore = NSWorkspace.shared.frontmostApplication + NSApp.activate(ignoringOtherApps: true) + } + + private func reposition() { + let scale = screenUnderCursor()?.backingScaleFactor ?? 2.0 + circlePanel?.center(on: currentCursor, scale: scale) + updateCardPanel() + } + + /// Shows the readout card beside the cursor for the `.card` theme (and positions it as the + /// cursor moves); orders it away for the other themes, which carry their readouts on the disc. + private func updateCardPanel() { + guard let cardPanel else { return } + if Defaults[.loupeTheme] == .card { + cardPanel.position(near: currentCursor, circleRadius: LoupeCircle.cardGlass / 2) + cardPanel.orderFrontRegardless() + // The card just jumped to the front; keep the click-catcher above it so clicks and + // scroll still land on the catcher rather than falling through. + catcher?.orderFrontRegardless() + } else { + cardPanel.orderOut(nil) + } + } + + // MARK: - Commit / cancel / teardown + + /// The eyedropper being picked into, for the live preview. Resolved live so it always + /// reflects `viewModel.target` (which flips to `.background` on a chained pair pick). + private var targetEyedropper: Eyedropper? { + guard let eyedroppers = AppDelegate.shared?.eyedroppers else { return nil } + return viewModel.target == .foreground ? eyedroppers.foreground : eyedroppers.background + } + + private func commit() { + // Ignore stray events once the pick has ended (e.g. an orphaned catcher tracking area + // still delivering after teardown): only a live pick has a completion. + guard completion != nil else { return } + finish(with: viewModel.sampleColor) + } + + private func finish(with color: NSColor?) { + guard completion != nil else { return } + pickGeneration += 1 // invalidate any in-flight capture belonging to this pick + // A cancelled pick reverts the live preview; a committed one keeps it (the caller's + // completion re-sets the same colour and posts `.colorPicked`, which records history). + if color == nil, let previewOriginal { + targetEyedropper?.set(previewOriginal) + } + previewOriginal = nil + + let callback = completion + completion = nil + + if color != nil, willChain { + // A foreground pick that hands off to the background: keep the loupe up so + // the next `begin` reuses it. Guard against a handoff that never arrives. + rearmSafety?.invalidate() + rearmSafety = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in + self?.teardown() + } + } else { + // Commit/cancel can arrive from inside the CGEventTap callback (click, right-click, + // Escape). Tearing down there removes the tap and its run-loop source from within the + // tap's own callback, which leaves teardown half-done — the catcher keeps tracking and + // the tap keeps firing. Mark inactive now (so nothing re-arms), and run teardown on the + // next tick, cleanly outside the callback. + isActive = false + DispatchQueue.main.async { [weak self] in self?.teardown() } + } + + callback?(color) + } + + private func teardown() { + isActive = false + if cursorHidden { + CGDisplayShowCursor(CGMainDisplayID()) + cursorHidden = false + } + rearmSafety?.invalidate() + rearmSafety = nil + removeMonitors() + isCapturing = false + pendingCapture = false + configuredDisplayID = nil + baseFilter = nil + // Rebuild the name lookup next pick so it reflects the active colour list. + closestVector = nil + colorNames = [] + circlePanel?.orderOut(nil) + cardPanel?.orderOut(nil) + catcher?.orderOut(nil) + + // Restore focus to whatever app we took it from for key handling. + if let appToRestore { + appToRestore.activate() + self.appToRestore = nil + } + } + + // MARK: - Event monitors + + private func installMonitors() { + guard localMonitors.isEmpty, eventTap == nil else { return } + // Prefer the global event tap; fall back to NSEvent monitors only if it can't be created + // (Accessibility not granted). NSEvent monitors only see input dispatched to Pika, so + // they work over Pika's own window but NOT over other apps — the tap works everywhere. + // Pointer *movement* always rides the catcher's tracking area (`onMoved`). + if installEventTap() { return } + let localKey = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + (self?.handleKeyDown(event) ?? false) ? nil : event + } + let globalKey = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + _ = self?.handleKeyDown(event) + } + let scroll = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in + self?.handleScroll(deltaY: Double(event.deltaY)); return nil + } + let leftDown = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] _ in + self?.commit(); return nil + } + let rightDown = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in + self?.cancel(); return nil + } + localMonitors.append(contentsOf: [localKey, globalKey, scroll, leftDown, rightDown].compactMap { $0 }) + } + + /// Installs a session-level event tap that intercepts and swallows clicks/scroll/keys for the + /// duration of the pick, over ANY app. Returns false if it can't be created (no Accessibility). + private func installEventTap() -> Bool { + let mask: CGEventMask = (1 << CGEventType.leftMouseDown.rawValue) + | (1 << CGEventType.rightMouseDown.rawValue) + | (1 << CGEventType.scrollWheel.rawValue) + | (1 << CGEventType.keyDown.rawValue) + // A capture-less closure so it bridges to a C function pointer; `self` arrives via refcon. + let callback: CGEventTapCallBack = { _, type, event, refcon in + guard let refcon else { return Unmanaged.passUnretained(event) } + let controller = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + return controller.handleTapEvent(type: type, event: event) + } + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, // .defaultTap can alter/discard events; requires Accessibility + eventsOfInterest: mask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { return false } + + eventTap = tap + let source = CFMachPortCreateRunLoopSource(nil, tap, 0) + eventTapSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + return true + } + + /// Handles a tapped event on the main run loop. Returns nil to swallow, or the event to pass. + private func handleTapEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { + switch type { + case .leftMouseDown: + commit(); return nil + case .rightMouseDown: + cancel(); return nil + case .scrollWheel: + handleScroll(deltaY: event.getDoubleValueField(.scrollWheelEventDeltaAxis1)); return nil + case .keyDown: + let handled = NSEvent(cgEvent: event).map { handleKeyDown($0) } ?? false + return handled ? nil : Unmanaged.passUnretained(event) + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) } + return Unmanaged.passUnretained(event) + default: + return Unmanaged.passUnretained(event) + } + } + + private func removeMonitors() { + if let eventTap { + CGEvent.tapEnable(tap: eventTap, enable: false) + if let eventTapSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapSource, .commonModes) } + self.eventTap = nil + eventTapSource = nil + } + for monitor in localMonitors { + NSEvent.removeMonitor(monitor) + } + localMonitors.removeAll() + } + + private func handlePointerMoved() { + // The catcher's tracking area can keep firing after teardown (orderOut doesn't always + // stop it); ignore moves unless a pick is live so the preview can't drift afterwards. + guard isActive else { return } + currentCursor = NSEvent.mouseLocation + reposition() + requestCapture() + } + + private func handleScroll(deltaY: Double) { + // Scroll up zooms in (fewer pixels across), down zooms out. + if deltaY > 0 { + viewModel.zoomIn() + } else if deltaY < 0 { + viewModel.zoomOut() + } + requestCapture() + } + + /// Returns `true` when the key was handled (and should be swallowed). + private func handleKeyDown(_ event: NSEvent) -> Bool { + // Arrow keys nudge one device pixel; Shift+arrow jumps ten for coarser moves. + let step = event.modifierFlags.contains(.shift) ? 10 : 1 + switch event.keyCode { + case 53: // Escape + cancel() + return true + case 36, 76: // Return and keypad Enter — commit the current sample (pairs with arrow nudging) + commit() + return true + case 24, 69: // = / + and keypad + + viewModel.zoomIn(); requestCapture(); return true + case 27, 78: // - and keypad - + viewModel.zoomOut(); requestCapture(); return true + case 123: nudge(dx: -step, dy: 0); return true // left + case 124: nudge(dx: step, dy: 0); return true // right + case 125: nudge(dx: 0, dy: -step); return true // down + case 126: nudge(dx: 0, dy: step); return true // up + case 48 where !event.modifierFlags.contains(.command): + cycleLoupeTheme(reverse: event.modifierFlags.contains(.shift)); return true // Tab + default: + // Swallow bare keys so Pika's own single-key shortcuts (x to swap, h/p/c, the + // format keys) can't fire mid-pick — but ONLY while Pika is frontmost. The + // CGEventTap is session-wide, so if the user ⌘-Tabs to another app during a pick we + // must let that app's keystrokes through: they aren't ours to eat, and Pika's + // shortcuts can't fire when it isn't active anyway. Command combos always pass. + return NSApp.isActive && !event.modifierFlags.contains(.command) + } + } + + /// Steps the live loupe through the available themes (Shift+Tab reverses), so you can + /// switch styles mid-pick without going into Settings. Persists the choice. + private func cycleLoupeTheme(reverse: Bool) { + let themes = LoupeTheme.allCases + guard let index = themes.firstIndex(of: Defaults[.loupeTheme]) else { return } + let next = (index + (reverse ? -1 : 1) + themes.count) % themes.count + Defaults[.loupeTheme] = themes[next] + // Show/hide the card beside the disc for the new theme, and rebuild the capture + // filter so a newly-shown card panel is excluded from the magnified image. + updateCardPanel() + configuredDisplayID = nil + requestCapture() + } + + /// Nudges the sample point by one device pixel by warping the cursor, for precise + /// single-pixel picks. `dy` is in Cocoa orientation (up is positive). + private func nudge(dx: Int, dy: Int) { + guard let screen = screenUnderCursor() else { return } + let step = 1.0 / screen.backingScaleFactor + let target = NSPoint(x: currentCursor.x + CGFloat(dx) * step, + y: currentCursor.y + CGFloat(dy) * step) + // CGWarp uses a top-left origin anchored on the primary display. + let primaryHeight = (NSScreen.screens.first { $0.frame.origin == .zero } ?? screen).frame.height + CGWarpMouseCursorPosition(CGPoint(x: target.x, y: primaryHeight - target.y)) + currentCursor = target + reposition() + requestCapture() + } + + // MARK: - Capture + + private func screenUnderCursor() -> NSScreen? { + NSScreen.screens.first { NSMouseInRect(currentCursor, $0.frame, false) } ?? NSScreen.main + } + + /// Whether the cursor is over one of Pika's own windows (excluding the loupe panels). Used to + /// fall back to the current colours instead of sampling — and feeding back — Pika's own UI. + private func isCursorOverAppWindow() -> Bool { + let loupeNumbers = Set(loupeWindowIDs.map { Int($0) }) + return NSApp.windows.contains { window in + window.isVisible + && !loupeNumbers.contains(window.windowNumber) + && window.frame.contains(currentCursor) + } + } + + private func requestCapture() { + guard isActive else { return } // no captures once the pick has ended + guard !isCapturing else { pendingCapture = true; return } + isCapturing = true + Task { @MainActor in + await performCapture() + isCapturing = false + if pendingCapture { + pendingCapture = false + requestCapture() + } + } + } + + @MainActor + private func performCapture() async { + let generation = pickGeneration + let cursor = currentCursor + guard let screen = screenUnderCursor() else { return } + let displayID = screen.displayID + + if configuredDisplayID != displayID || baseFilter == nil { + await configureFilter(for: displayID) + } + guard let filter = baseFilter else { return } + + let pixelCount = viewModel.pixelCount + let scale = screen.backingScaleFactor + + // Capture ONLY a small window around the cursor — not the whole display (tens of MB per + // frame on a large screen). But a `sourceRect` with a SUB-POINT origin makes + // ScreenCaptureKit sample *between* pixels and softens the magnifier. So snap the rect to + // WHOLE POINTS that fully enclose the magnifier window and capture at its exact + // device-pixel size (1:1, no resample), then crop the exact window out of the result with + // `CGImage.cropping` — a pure pixel op that keeps it hard-edged and the sample exact. + let displayWpx = Int((screen.frame.width * scale).rounded()) + let displayHpx = Int((screen.frame.height * scale).rounded()) + // Magnifier window in device pixels (top-left origin), clamped so it stays on-screen. + let localX = (cursor.x - screen.frame.minX) * scale + let localYTop = (screen.frame.height - (cursor.y - screen.frame.minY)) * scale + let half = pixelCount / 2 + let winX = min(max(0, Int(localX.rounded()) - half), max(0, displayWpx - pixelCount)) + let winY = min(max(0, Int(localYTop.rounded()) - half), max(0, displayHpx - pixelCount)) + + // Whole-point rect (top-left origin, points) that encloses the window, so SCK samples on + // point boundaries with no sub-point blend. + let originPtX = (Double(winX) / scale).rounded(.down) + let originPtY = (Double(winY) / scale).rounded(.down) + let regionPtW = (Double(winX + pixelCount) / scale).rounded(.up) - originPtX + let regionPtH = (Double(winY + pixelCount) / scale).rounded(.up) - originPtY + + let config = SCStreamConfiguration() + config.sourceRect = CGRect(x: originPtX, y: originPtY, width: regionPtW, height: regionPtH) + config.width = Int((regionPtW * scale).rounded()) + config.height = Int((regionPtH * scale).rounded()) + config.showsCursor = false + config.colorSpaceName = captureColorSpaceName() + + do { + let region = try await SCScreenshotManager.captureImage( + contentFilter: filter, configuration: config + ) + // The grab is async: if the pick was cancelled/committed (or a new one began) while + // it was in flight, this result is stale — discard it so it can't write a colour + // into a finished pick (the cause of a straggler landing on Escape or a drag). + guard generation == pickGeneration else { return } + // Crop the exact magnifier window out of the whole-point capture (pure pixel op). + let offsetX = winX - Int((originPtX * scale).rounded()) + let offsetY = winY - Int((originPtY * scale).rounded()) + let cropped = region.cropping( + to: CGRect(x: offsetX, y: offsetY, width: pixelCount, height: pixelCount) + ) ?? region + viewModel.image = cropped + let pixel = Self.centerPixelColor(of: cropped) ?? viewModel.sampleColor + // Over Pika's own UI, fall back to the colour the pick started with so sampling + // doesn't feed the swatch back into itself (and fade the loupe to signal that). + let overApp = isCursorOverAppWindow() + viewModel.isOverApp = overApp + if overApp, let previewOriginal { + viewModel.sampleColor = previewOriginal + } else { + viewModel.sampleColor = pixel + } + // Live-preview the sample into the app (footer / swatches track the cursor) only + // while a leg is actively sampling. `completion` is nil between the foreground commit + // and the deferred background `begin()` of a pair pick — the loupe stays up + // (`isActive`) but `viewModel.target` is still `.foreground`, so pushing here would + // overwrite the just-committed foreground colour with whatever's under the cursor. + if isActive, completion != nil { + targetEyedropper?.set(viewModel.sampleColor) + } + updateColorName() + } catch { + // Transient capture failures (display reconfigured, filter stale) are ignored; + // the next mouse move retries. Force a filter rebuild so we recover. + configuredDisplayID = nil + } + } + + /// Updates the closest colour name for the sample (both themes show it). The lookup + /// vector is built once per pick from the active colour list. + private func updateColorName() { + viewModel.colorName = closestColorName(for: viewModel.sampleColor) + } + + /// Closest colour name for `color`, building the lookup vector on first use. + private func closestColorName(for color: NSColor) -> String { + if closestVector == nil { + colorNames = ColorNamesManager.shared.currentColorNames() + closestVector = ClosestVector(colorNames.map { $0.color.toRGB8BitArray() }) + } + guard let closestVector, !colorNames.isEmpty else { return "" } + let index = closestVector.compare(color) + return colorNames.indices.contains(index) ? colorNames[index].name : "" + } + + private func configureFilter(for displayID: CGDirectDisplayID) async { + do { + let content = try await SCShareableContent.current + guard let display = content.displays.first(where: { $0.displayID == displayID }) else { return } + let excluded = content.windows.filter { loupeWindowIDs.contains($0.windowID) } + baseFilter = SCContentFilter(display: display, excludingWindows: excluded) + configuredDisplayID = displayID + } catch { + baseFilter = nil + configuredDisplayID = nil + } + } + + /// Capture in a known colour space and convert deliberately in the commit path — + /// sRGB by default, Display P3 when the accuracy preference calls for it. + private func captureColorSpaceName() -> CFString { + // Compare the NSColorSpace directly, the way the rest of the app does + // (see PreferencesView `space == NSColorSpace.displayP3`). Substring- + // matching `localizedName` for "P3" was fragile — e.g. "Adobe RGB (1998)" + // never matched and silently fell through to sRGB. + Defaults[.colorSpace] == .displayP3 ? CGColorSpace.displayP3 : CGColorSpace.sRGB + } + + static func centerPixelColor(of image: CGImage) -> NSColor? { + let x = max(0, image.width / 2) + let y = max(0, image.height / 2) + + // Read the raw pixel and build the colour in the image's *exact* colour space (the + // one we captured in — sRGB or Display P3). `NSBitmapImageRep.colorAt` reinterprets + // the pixel through an intermediate device/calibrated space, so the sampled colour + // didn't match what was on screen and drifted when re-picking Pika's own rendered + // swatch. Anything but the standard ScreenCaptureKit layout falls back to colorAt. + func fallback() -> NSColor? { + NSBitmapImageRep(cgImage: image).colorAt(x: x, y: y) + } + + guard image.bitsPerComponent == 8, image.bitsPerPixel == 32, + let cgColorSpace = image.colorSpace, + let colorSpace = NSColorSpace(cgColorSpace: cgColorSpace), + let data = image.dataProvider?.data, + let ptr = CFDataGetBytePtr(data) + else { return fallback() } + + // ScreenCaptureKit hands back 32-bit BGRA (little-endian, alpha-first); screen + // pixels are opaque, so no un-premultiply is needed. + let alphaFirst = image.alphaInfo == .premultipliedFirst || image.alphaInfo == .first + || image.alphaInfo == .noneSkipFirst + guard image.bitmapInfo.intersection(.byteOrderMask) == .byteOrder32Little, alphaFirst + else { return fallback() } + + let offset = y * image.bytesPerRow + x * 4 + let blue = CGFloat(ptr[offset]) / 255.0 + let green = CGFloat(ptr[offset + 1]) / 255.0 + let red = CGFloat(ptr[offset + 2]) / 255.0 + return NSColor(colorSpace: colorSpace, components: [red, green, blue, 1.0], count: 4) + } +} + +extension NSScreen { + /// The `CGDirectDisplayID` backing this screen. + var displayID: CGDirectDisplayID { + (deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) ?? CGMainDisplayID() + } +} + +/// Observable state driving the loupe SwiftUI view. Updated on the main thread by the +/// controller after each capture. +final class LoupeViewModel: ObservableObject { + @Published var image: CGImage? + @Published var sampleColor: NSColor = .black + @Published var colorName: String = "" + @Published var target: Eyedropper.Types = .foreground + @Published var comparison: NSColor? + /// Closest-colour name for `comparison`, resolved once per pick (the pair doesn't change). + @Published var comparisonName: String = "" + @Published var pixelCount: Int = 15 + /// True while the cursor is over one of Pika's own windows — the loupe fades to signal it + /// won't sample there (it falls back to the current colours instead). + @Published var isOverApp: Bool = false + + private let minPixels = 5 + private let maxPixels = 41 + + func zoomIn() { pixelCount = max(minPixels, pixelCount - 2) } + func zoomOut() { pixelCount = min(maxPixels, pixelCount + 2) } +} diff --git a/Pika/Services/Eyedropper.swift b/Pika/Services/Eyedropper.swift index 021437b2..acfd5588 100644 --- a/Pika/Services/Eyedropper.swift +++ b/Pika/Services/Eyedropper.swift @@ -1,3 +1,4 @@ +import Combine import Defaults import SwiftUI @@ -60,23 +61,62 @@ class Eyedropper: ObservableObject { var forceShow = false var pendingChainCommit = false - let colorNames: [ColorName] = loadColors()! - var closestVector: ClosestVector! + // Retains the in-flight pick session for the duration of an async pick so it + // (and its event monitors / capture engine, for the custom loupe) stays alive. + private var activeSession: ColorPickSession? + + // Colour names come from the shared manager (cached network list, or the bundled + // default offline). Rebuilt whenever the manager broadcasts `.colorNamesUpdated`. + private var colorNames: [ColorName] = [] + private var closestVector: ClosestVector? @objc @Published public var color: NSColor + /// Fires the instant this eyedropper's colour is set by a genuine screen pick (not a typed + /// edit) — lets the swatch flash briefly so a chained foreground-then-background pick reads + /// as two distinct events instead of one silent colour swap. + let pickFlash = PassthroughSubject() + private var overlayWindow = ColorPickOverlayWindow() init(type: Types, color: NSColor) { self.type = type self.color = color.usingColorSpace(.sRGB) ?? color - // Load colors - closestVector = ClosestVector(colorNames.map { $0.color.toRGB8BitArray() }) + // Load colours and rebuild whenever the active list changes or a refresh lands. + reloadColorNames() + NotificationCenter.default.addObserver( + self, + selector: #selector(handleColorNamesUpdated), + name: .colorNamesUpdated, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func handleColorNamesUpdated() { + reloadColorNames() + // Nudge observing views (the eyedropper label) to recompute the name. + DispatchQueue.main.async { self.objectWillChange.send() } + } + + private func reloadColorNames() { + DispatchQueue.global(qos: .utility).async { [weak self] in + let names = ColorNamesManager.shared.currentColorNames() + let vector = ClosestVector(names.map { $0.color.toRGB8BitArray() }) + DispatchQueue.main.async { + self?.colorNames = names + self?.closestVector = vector + } + } } func getClosestColor() -> String { - colorNames[closestVector.compare(color)].name + guard let closestVector, !colorNames.isEmpty else { return "" } + return colorNames[closestVector.compare(color)].name } func set(_ selectedColor: NSColor) { @@ -127,10 +167,33 @@ extension Eyedropper { if Defaults[.appMode].usesPopover { NSApp.activate(ignoringOtherApps: true) } - let sampler = NSColorSampler() - sampler.show { selectedColor in + + // Choose the picking UI by preference. The commit path below is shared + // and identical for both sessions — only the pick surface differs, so + // downstream behaviour (set / history / undo / overlay / chaining) + // cannot fork. With `.system` this is byte-for-byte today's flow. + let willChain = chainContrasting && self.type == .foreground + // `AppDelegate.shared`, not `NSApp.delegate` — the latter is SwiftUI's forwarding + // wrapper under `@NSApplicationDelegateAdaptor`, so `as? AppDelegate` is always + // nil and the loupe would never get a comparison colour (no live contrast). + let comparison: NSColor? = AppDelegate.shared.map { + self.type == .foreground ? $0.eyedroppers.background.color : $0.eyedroppers.foreground.color + } + let useCustom = Defaults[.pickerStyle] == .custom && CustomColorPickSession.isAvailable + if Defaults[.pickerStyle] == .custom, !useCustom { + // Permission was revoked since the picker was enabled: fall back to + // the system sampler for this pick and revert the preference, telling + // the user once. A pick must never fail because custom is unavailable. + Defaults[.pickerStyle] = .system + CustomColorPickSession.notePermissionRevertedOnce() + } + let session: ColorPickSession = useCustom + ? CustomColorPickSession() + : SystemColorPickSession() + self.activeSession = session + session.begin(target: self.type, comparison: comparison, willChain: willChain) { selectedColor in if let selectedColor = selectedColor { - self.commitPick(selectedColor, chainContrasting: chainContrasting) + self.commitPick(selectedColor, chainContrasting: chainContrasting, useCustom: useCustom) } else if self.pendingChainCommit { self.commitCancelledChain() } else { @@ -149,14 +212,18 @@ extension Eyedropper { if panel.isVisible { self.picker() } + + self.activeSession = nil } } } - private func commitPick(_ selectedColor: NSColor, chainContrasting: Bool) { + private func commitPick(_ selectedColor: NSColor, chainContrasting: Bool, useCustom: Bool) { let normalizedColor = selectedColor.usingColorSpace(.sRGB) ?? selectedColor - if Defaults[.showColorOverlay] { + // The custom loupe already shows the colour live during the pick, so the + // post-pick overlay is redundant when it's active. + if Defaults[.showColorOverlay], !useCustom { let colorText = normalizedColor.toFormat( format: Defaults[.colorFormat], style: Defaults[.copyFormat] ) @@ -170,12 +237,13 @@ extension Eyedropper { } set(normalizedColor) + pickFlash.send() if chainContrasting, type == .foreground, let appDelegate = AppDelegate.shared { - startChainedBackgroundPick(using: appDelegate) + startChainedBackgroundPick(using: appDelegate, useCustom: useCustom) } else { pendingChainCommit = false NotificationCenter.default.post(name: .colorPicked, object: nil) @@ -183,7 +251,7 @@ extension Eyedropper { } } - private func startChainedBackgroundPick(using appDelegate: AppDelegate) { + private func startChainedBackgroundPick(using appDelegate: AppDelegate, useCustom: Bool) { // Defer committing the foreground pick — we'll record once the // background is also picked (or the chained pick is cancelled). let background = appDelegate.eyedroppers.background @@ -194,7 +262,7 @@ extension Eyedropper { forceShow = false background.forceShow = true } - let delay: Double = Defaults[.showColorOverlay] ? 0.4 : 0.05 + let delay: Double = (Defaults[.showColorOverlay] && !useCustom) ? 0.4 : 0.05 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { background.start() } diff --git a/Pika/Services/LoadColors.swift b/Pika/Services/LoadColors.swift index 5e57950c..31f84df7 100644 --- a/Pika/Services/LoadColors.swift +++ b/Pika/Services/LoadColors.swift @@ -1,4 +1,5 @@ import Cocoa +import Defaults struct ColorName: Decodable { public var name: String @@ -12,6 +13,19 @@ struct ResponseData: Decodable { var colors: [ColorName] } +/// A named colour list published by the color.pizza API (`/v1/lists/`). +struct ColorListInfo: Identifiable, Hashable { + let key: String + let title: String + var id: String { key } +} + +/// The key of the list bundled with the app as the offline fallback. It's downloaded as +/// part of the build so colour names always work without a network connection. +let defaultColorListKey = "default" + +/// Loads the colour list bundled at build time — the `default` list, used as the offline +/// fallback until (and if) a network refresh caches a newer copy. func loadColors() -> [ColorName]? { if let url = Bundle.main.url(forResource: "ColorNames", withExtension: "json") { do { @@ -25,3 +39,316 @@ func loadColors() -> [ColorName]? { } return nil } + +/// Owns colour-name data sourced from the color.pizza API, with an offline fallback. +/// +/// - The `default` list is downloaded during the build and bundled, so names always work +/// offline. +/// - On launch the currently-selected list is refreshed from the network and cached on disk. +/// - Settings and the splash let the user pick any list from `/v1/lists/`; if the chosen +/// list is no longer offered by the API, we fall back to `default`. +/// +/// Reloads are broadcast via `.colorNamesUpdated` so the eyedroppers rebuild their lookup. +final class ColorNamesManager: ObservableObject { + static let shared = ColorNamesManager() + + /// Lists offered by the API, for the picker UI. Empty until fetched from the network. + @Published private(set) var availableLists: [ColorListInfo] = [] + + /// True while a catalogue or colour fetch is in flight. Drives the picker's spinner. + @Published private(set) var isFetching = false + + /// When the active list's cached colours were last refreshed from the network (the + /// cache file's modification date), or nil if only the bundled default is in use. + @Published private(set) var lastUpdated: Date? + + /// A friendly description of the most recent refresh failure, cleared on success. Shown + /// in the picker's tooltip so an offline / API problem is visible rather than silent. + @Published private(set) var lastErrorMessage: String? + + private let session = URLSession.shared + private let apiBase = "https://api.color.pizza/v1" + + /// How often to re-check the API while the app keeps running. + private let refreshInterval: TimeInterval = 6 * 60 * 60 + /// Skip a foreground-triggered refresh if one was attempted within this window. + private let foregroundThrottle: TimeInterval = 30 * 60 + private var refreshTimer: Timer? + private var didBecomeActiveObserver: NSObjectProtocol? + private var lastRefreshAttempt: Date? + /// Count of in-flight requests, so overlapping fetches keep `isFetching` accurate. + private var inFlight = 0 + + private init() {} + + /// A human-readable summary of the current refresh state, for the picker's tooltip. + var statusDescription: String { + if isFetching { return PikaText.textColorListStatusChecking } + if let lastErrorMessage { return lastErrorMessage } + if let lastUpdated { + return String(format: PikaText.textColorListStatusUpdatedFormat, + Self.statusDateFormatter.string(from: lastUpdated)) + } + return PikaText.textColorListStatusBuiltIn + } + + // MARK: - Loading names for the current list + + /// The colour names for the currently-selected list: the on-disk cache if present, + /// otherwise the bundled `default` list. + func currentColorNames() -> [ColorName] { + let key = Defaults[.colorNameList] + if let url = Self.cacheURL(for: key), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(ResponseData.self, from: data), + !decoded.colors.isEmpty + { + return decoded.colors + } + return loadColors() ?? [] + } + + // MARK: - Launch & periodic update + + /// Kicks off the first refresh, then keeps the data current: a repeating timer re-checks + /// the API every few hours, and re-activating the app triggers a (throttled) refresh. + /// Safe to call once on launch; any failure leaves the cached / bundled data untouched. + func updateOnLaunch() { + refreshLastUpdated() + refreshAll() + schedulePeriodicRefresh() + observeAppActivation() + } + + /// Refreshes the catalogue and the selected list's colours. Falls the selection back to + /// `default` if the chosen list is no longer offered. Safe to call repeatedly. + func refreshAll() { + lastRefreshAttempt = Date() + fetchLists { [weak self] infos, availableKeys in + guard let self else { return } + if !infos.isEmpty { self.availableLists = infos } + + // Fall back to `default` if the chosen list is no longer available. + var key = Defaults[.colorNameList] + if !availableKeys.isEmpty, !availableKeys.contains(key) { + key = defaultColorListKey + Defaults[.colorNameList] = key + self.refreshLastUpdated() + NotificationCenter.default.post(name: .colorNamesUpdated, object: nil) + } + self.refreshColors(for: key) + } + } + + private func schedulePeriodicRefresh() { + guard refreshTimer == nil else { return } + let timer = Timer(timeInterval: refreshInterval, repeats: true) { [weak self] _ in + self?.refreshAll() + } + RunLoop.main.add(timer, forMode: .common) + refreshTimer = timer + } + + private func observeAppActivation() { + guard didBecomeActiveObserver == nil else { return } + didBecomeActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + guard let self else { return } + // Don't hammer the API every time focus returns — refresh at most twice an hour. + if let last = self.lastRefreshAttempt, + Date().timeIntervalSince(last) < self.foregroundThrottle { return } + self.refreshAll() + } + } + + /// Ensures the picker has a catalogue to show; used when Settings / the splash appear. + func loadAvailableListsIfNeeded() { + guard availableLists.isEmpty else { return } + fetchLists { [weak self] infos, _ in + if !infos.isEmpty { self?.availableLists = infos } + } + } + + /// Switches the active list: reflects the change immediately from cache / bundle, then + /// fetches the latest colours for the newly-chosen list in the background. + func selectList(_ key: String) { + guard key != Defaults[.colorNameList] else { return } + Defaults[.colorNameList] = key + refreshLastUpdated() + NotificationCenter.default.post(name: .colorNamesUpdated, object: nil) + refreshColors(for: key) + } + + private func refreshColors(for key: String) { + fetchColors(for: key) { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.lastErrorMessage = nil + // Only signal a reload if this is still the active list when the fetch lands. + guard Defaults[.colorNameList] == key else { return } + self.refreshLastUpdated() + NotificationCenter.default.post(name: .colorNamesUpdated, object: nil) + case let .failure(message): + self.lastErrorMessage = message + } + } + } + + /// Recomputes `lastUpdated` from the active list's cache file modification date. + private func refreshLastUpdated() { + lastUpdated = Self.cacheModificationDate(for: Defaults[.colorNameList]) + } + + // MARK: - Fetch-state tracking + + private func beginFetch() { + inFlight += 1 + isFetching = true + } + + private func endFetch() { + inFlight = max(0, inFlight - 1) + if inFlight == 0 { isFetching = false } + } + + // MARK: - Networking + + private enum FetchResult { + case success + case failure(String) + } + + private func fetchLists(completion: @escaping ([ColorListInfo], Set) -> Void) { + guard let url = URL(string: "\(apiBase)/lists/") else { completion([], []); return } + beginFetch() + session.dataTask(with: url) { [weak self] data, _, _ in + let parsed = data.flatMap { Self.parseLists($0) } + DispatchQueue.main.async { + self?.endFetch() + completion(parsed?.0 ?? [], parsed?.1 ?? []) + } + }.resume() + } + + private func fetchColors(for key: String, completion: @escaping (FetchResult) -> Void) { + guard let escaped = key.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed), + let url = URL(string: "\(apiBase)/?list=\(escaped)") + else { completion(.failure(PikaText.textColorListStatusOffline)); return } + beginFetch() + session.dataTask(with: url) { [weak self] data, _, error in + let result: FetchResult + if error != nil { + result = .failure(PikaText.textColorListStatusOffline) + } else if let data, + let decoded = try? JSONDecoder().decode(ResponseData.self, from: data), + !decoded.colors.isEmpty, + let cacheURL = Self.cacheURL(for: key), + (try? data.write(to: cacheURL, options: .atomic)) != nil + { + result = .success + } else { + result = .failure(PikaText.textColorListStatusOffline) + } + DispatchQueue.main.async { + self?.endFetch() + completion(result) + } + }.resume() + } + + // MARK: - Parsing + + private struct ListsResponse: Decodable { + let availableColorNameLists: [String]? + let listDescriptions: [String: ListDescription]? + } + + private struct ListDescription: Decodable { + let title: String? + } + + /// Parses `/v1/lists/` into `(orderedInfos, availableKeys)`, tolerating either the + /// `availableColorNameLists` array or the `listDescriptions` map being absent, and + /// guaranteeing that `default` is always present and listed first. + private static func parseLists(_ data: Data) -> ([ColorListInfo], Set)? { + guard let response = try? JSONDecoder().decode(ListsResponse.self, from: data) else { return nil } + + let descriptions = response.listDescriptions ?? [:] + // Prefer the authoritative `availableColorNameLists` ordering; otherwise use the described keys. + var keys = response.availableColorNameLists ?? Array(descriptions.keys).sorted() + guard !keys.isEmpty else { return nil } + + // Ensure the bundled default is always offered and appears first. + keys.removeAll { $0 == defaultColorListKey } + keys.insert(defaultColorListKey, at: 0) + + let infos = keys.map { key -> ColorListInfo in + // Keep `default` recognisable regardless of how the API labels it. + let title = key == defaultColorListKey + ? Self.prettify(key) + : (descriptions[key]?.title ?? Self.prettify(key)) + return ColorListInfo(key: key, title: title) + } + return (infos, Set(keys)) + } + + /// A readable title for a list key the API didn't describe: `sanzoWadaI` -> `Sanzo Wada I`. + private static func prettify(_ key: String) -> String { + if key == defaultColorListKey { return PikaText.textColorListDefault } + var result = "" + for (index, character) in key.enumerated() { + if index == 0 { + result.append(Character(String(character).uppercased())) + } else if character.isUppercase { + result.append(" ") + result.append(character) + } else { + result.append(character) + } + } + return result + } + + // MARK: - Cache location + + private static func cacheDirectory() -> URL? { + guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + else { return nil } + let dir = base.appendingPathComponent("Pika/ColorLists", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func cacheURL(for key: String) -> URL? { + // Guard against odd list keys resolving to unexpected paths. + let safeKey = key.replacingOccurrences(of: "/", with: "_") + return cacheDirectory()?.appendingPathComponent("\(safeKey).json") + } + + private static func cacheModificationDate(for key: String) -> Date? { + guard let url = cacheURL(for: key), + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + else { return nil } + return attributes[.modificationDate] as? Date + } + + // MARK: - Formatting + + private static let statusDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} + +private extension CharacterSet { + /// Query-value-safe set: the query set minus the sub-delims that break `?list=` values. + static let urlQueryValueAllowed: CharacterSet = { + var set = CharacterSet.urlQueryAllowed + set.remove(charactersIn: "&=?+/") + return set + }() +} diff --git a/Pika/Services/PickerLoupePanel.swift b/Pika/Services/PickerLoupePanel.swift new file mode 100644 index 00000000..db3c7ff8 --- /dev/null +++ b/Pika/Services/PickerLoupePanel.swift @@ -0,0 +1,218 @@ +import AppKit +import SwiftUI + +/// The circular magnifier panel, centred on the cursor (system-loupe style). Borderless, +/// non-activating, and ignores mouse events so the committing click lands on the +/// full-screen catcher beneath it. It can still become key (without activating Pika) so +/// it receives Escape / zoom / nudge keys. +/// +/// See `plans/ready/2026-07-19-custom-color-picker.md`. +final class LoupeCirclePanel: NSPanel { + private let hostingView: NSHostingView + + init(viewModel: LoupeViewModel) { + hostingView = NSHostingView(rootView: LoupeCircle(viewModel: viewModel)) + + let side = LoupeCircle.totalSize + super.init( + contentRect: NSRect(x: 0, y: 0, width: side, height: side), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isFloatingPanel = true + level = .screenSaver + backgroundColor = .clear + isOpaque = false + hasShadow = false // LoupeCircle draws its own shadow inside the padded frame. + titlebarAppearsTransparent = true + titleVisibility = .hidden + isMovable = false + ignoresMouseEvents = true + hidesOnDeactivate = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + + contentView = hostingView + } + + // Borderless panels don't become key by default; the loupe needs key status to + // receive Escape without activating Pika or deactivating the sampled app. + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + /// Centres the disc on the cursor. The padded frame is symmetric, so centring the + /// window centres the circle (and its sampled centre pixel) on the cursor. + /// + /// The origin is snapped to the device-pixel grid: the magnified image is nearest- + /// neighbour pixel-art, and a fractional window origin composites it at a sub-pixel + /// offset, which the compositor anti-aliases — so it looks crisp at some cursor + /// positions and blurs when the cursor sits half a device pixel over. Snapping keeps it + /// hard-edged everywhere. (`NSEvent.mouseLocation` is sub-pixel, hence the fractional origin.) + func center(on cursor: NSPoint, scale: CGFloat) { + let size = frame.size + let rawX = cursor.x - size.width / 2 + let rawY = cursor.y - size.height / 2 + setFrameOrigin(NSPoint(x: (rawX * scale).rounded() / scale, + y: (rawY * scale).rounded() / scale)) + } +} + +/// The readout card panel, tucked beside the loupe circle for the `.card` theme. Display-only: +/// borderless, non-activating, and ignores mouse events. +final class LoupeCardPanel: NSPanel { + private let hostingView: NSHostingView + /// Gap between the circle's edge and the nearest card edge. + private let gap: CGFloat = 14 + + init(viewModel: LoupeViewModel) { + hostingView = NSHostingView(rootView: LoupeReadoutCard(viewModel: viewModel)) + + super.init( + contentRect: NSRect(x: 0, y: 0, width: 176, height: 96), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isFloatingPanel = true + level = .screenSaver + backgroundColor = .clear + isOpaque = false + hasShadow = true + titlebarAppearsTransparent = true + titleVisibility = .hidden + isMovable = false + ignoresMouseEvents = true + hidesOnDeactivate = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + + contentView = hostingView + } + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + /// Positions the card beside the cursor, clear of the circle, flipping and clamping so + /// it stays on the screen under the cursor. + func position(near cursor: NSPoint, circleRadius: CGFloat) { + let size = hostingView.fittingSize + setContentSize(size) + + let clearance = circleRadius + gap + let screen = NSScreen.screens.first { NSMouseInRect(cursor, $0.frame, false) } ?? NSScreen.main + guard let frame = screen?.visibleFrame else { + setFrameOrigin(NSPoint(x: cursor.x + clearance, y: cursor.y - size.height / 2)) + return + } + + // Prefer to the right of the circle, vertically centred on the cursor; flip to the + // left near the right edge, then clamp on both axes. + var originX = cursor.x + clearance + var originY = cursor.y - size.height / 2 + + if originX + size.width > frame.maxX { originX = cursor.x - clearance - size.width } + if originX < frame.minX { originX = frame.minX + 8 } + if originX + size.width > frame.maxX { originX = frame.maxX - size.width - 8 } + + if originY < frame.minY { originY = frame.minY + 8 } + if originY + size.height > frame.maxY { originY = frame.maxY - size.height - 8 } + + setFrameOrigin(NSPoint(x: originX, y: originY)) + } +} + +/// A full-screen, transparent panel that sits above every other app (but below the loupe) +/// while a pick is active. Its whole job is to *consume* the committing click so it never +/// reaches the desktop / app underneath — global `NSEvent` monitors can only observe the +/// click, not swallow it. It also drives cursor tracking, scroll-to-zoom, and right-click +/// cancel, since with the catcher in front those events are delivered locally to Pika. +final class LoupeClickCatcherPanel: NSPanel { + var onCommit: (() -> Void)? + var onCancel: (() -> Void)? + var onMoved: (() -> Void)? + var onScroll: ((NSEvent) -> Void)? + + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isFloatingPanel = true + // Same level as the loupe panels. The controller keeps the catcher ordered front-most + // (re-asserting it after the card panel re-orders itself on cursor moves) so it always + // swallows clicks/scroll; being transparent, it doesn't hide the lens. A non-activating + // panel at this level can still become key for Escape/zoom/nudge — pushing it to a + // higher level breaks that, leaking scroll and keys to the app behind. + level = .screenSaver + backgroundColor = .clear + isOpaque = false + hasShadow = false + ignoresMouseEvents = false // The whole point: receive (and swallow) the click. + acceptsMouseMovedEvents = true + hidesOnDeactivate = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + + let view = LoupeClickCatcherView() + view.owner = self + contentView = view + } + + // Becomes key (without activating Pika) so Escape / zoom / nudge reach the controller's + // key monitor while a pick is active — it's the full-screen surface in front of every + // other window, so it's the natural key window for the pick. + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + /// Covers the bounding rect of all screens so a click anywhere is intercepted. + func cover(screens: [NSScreen]) { + let union = screens.reduce(CGRect.null) { $0.union($1.frame) } + let target = union.isNull ? (NSScreen.main?.frame ?? .zero) : union + setFrame(target, display: false) + } +} + +/// Backing view for `LoupeClickCatcherPanel`. Consumes mouse-down (by not forwarding to +/// `super`) so the click dies here instead of reaching the desktop, and reports pointer +/// movement via a tracking area so the loupe follows the cursor. +private final class LoupeClickCatcherView: NSView { + weak var owner: LoupeClickCatcherPanel? + private var trackingAreaRef: NSTrackingArea? + + override var acceptsFirstResponder: Bool { true } + // Deliver (and let us consume) the very first click even though the catcher isn't the + // active window — otherwise the first click would just activate it and slip through. + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingAreaRef { removeTrackingArea(trackingAreaRef) } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingAreaRef = area + } + + // `mouseMoved`/`mouseDragged` are the live path: pointer movement isn't tapped or monitored, + // so cursor tracking always rides these. + override func mouseMoved(with _: NSEvent) { owner?.onMoved?() } + override func mouseDragged(with _: NSEvent) { owner?.onMoved?() } + + // The click/scroll overrides are a best-effort fallback, not the primary path. With the + // CGEventTap active it swallows these at the session level before AppKit dispatches them + // here; in the no-Accessibility fallback the local NSEvent monitors intercept them first — + // BUT those local monitors only fire while Pika is the active app. If a pick is running + // without Accessibility and over another app (Pika not frontmost), neither fires, and this + // front-most catcher receiving the click via `acceptsFirstMouse` is the only commit path + // left. Kept intentionally for that case; consumed (no `super`) so the click dies here. + override func mouseDown(with _: NSEvent) { owner?.onCommit?() } + override func rightMouseDown(with _: NSEvent) { owner?.onCancel?() } + override func scrollWheel(with event: NSEvent) { owner?.onScroll?(event) } +} diff --git a/Pika/Services/PikaWindow.swift b/Pika/Services/PikaWindow.swift index 41f178ec..25c0f518 100644 --- a/Pika/Services/PikaWindow.swift +++ b/Pika/Services/PikaWindow.swift @@ -79,7 +79,13 @@ class PikaWindow { } window.title = title - window.level = (Defaults[.appFloating] ? .floating : .normal) + 1 + // Secondary windows (splash, About, Help, Preferences) track the same + // level as the main Pika window so they don't get buried behind it when + // "float on top" is enabled (`appFloating` defaults to true, putting the + // main window at `.floating`). System dialogs like the Screen Recording + // permission prompt sit above `.floating` regardless, so this stays below + // them. They also come to the front when opened via `makeKeyAndOrderFront`. + window.level = Defaults[.appFloating] ? .floating : .normal window.isMovableByWindowBackground = true window.center() window.setFrameAutosaveName("\(title) Window") diff --git a/Pika/Services/WindowCoordinator.swift b/Pika/Services/WindowCoordinator.swift index cce52646..91d6a2fb 100644 --- a/Pika/Services/WindowCoordinator.swift +++ b/Pika/Services/WindowCoordinator.swift @@ -75,11 +75,17 @@ class WindowCoordinator: NSObject { // Keep the companion windows on the same level as the main window (which PikaWindow // moves between .floating/.normal), so toggling "float on top" doesn't leave them - // stranded on a stale level. + // stranded on a stale level. About/Help/Preferences are cached after first open + // (see setupAbout/Help/Preferences below), so without this they'd stay stuck at + // whatever level they were created with. Defaults.observe(.appFloating) { [weak self] change in let level: NSWindow.Level = change.newValue == true ? .floating : .normal self?.borderWindow?.level = level self?.shadowWindow?.level = level + self?.aboutWindow?.level = level + self?.helpWindow?.level = level + self?.preferencesWindow?.level = level + self?.splashWindow?.level = level }.tieToLifetime(of: self) // Keep the companion windows aligned to the main window as it resizes and moves. @@ -323,8 +329,12 @@ class WindowCoordinator: NSObject { } func startMainWindow() { - if !pikaWindow.isVisible { + // Popover mode clears `pikaWindow.contentView` (see `removeMainWindowContent()`), + // so fading it in here — as this completion handler used to unconditionally do — + // would show a blank floating window whenever the splash is dismissed in that mode. + if !Defaults[.appMode].usesPopover, !pikaWindow.isVisible { pikaWindow.fadeIn(nil) + steerFirstResponderAwayFromFields() } applyShadowState() Defaults[.viewedSplash] = true @@ -332,9 +342,23 @@ class WindowCoordinator: NSObject { func showMainWindow() { pikaWindow.makeKeyAndOrderFront(nil) + steerFirstResponderAwayFromFields() applyShadowState() } + /// AppKit's own auto-focus (`_setUpFirstResponder`/`_selectFirstKeyView`) is meant to be + /// headed off once and for all by pointing `initialFirstResponder` at the content view (see + /// the comment at that assignment in `ScrubTextField.viewDidMoveToWindow`). + /// That holds for the window's very first appearance, but re-showing a window that was + /// previously ordered out (e.g. a pick landing while Pika was closed, which unconditionally + /// re-shows it via `showPika`) can still land on the first colour-value field instead — + /// resigning key status on hide appears to drop the current first responder, and re-deriving + /// one on the way back in doesn't always respect `initialFirstResponder`. Rather than chase + /// that AppKit-internal quirk, force it back every time the window is (re)shown. + private func steerFirstResponderAwayFromFields() { + pikaWindow.makeFirstResponder(pikaWindow.contentView) + } + func hideMainWindow() { pikaWindow.orderOut(nil) } @@ -360,6 +384,7 @@ class WindowCoordinator: NSObject { } else { pikaWindow.fadeIn(sender: nil, duration: 0.2) } + steerFirstResponderAwayFromFields() applyShadowState() NSApp.activate(ignoringOtherApps: true) } @@ -405,8 +430,12 @@ class WindowCoordinator: NSObject { func openPreferencesWindow() { if preferencesWindow == nil, let eyedroppers { + // Width is pinned by the window's own min/max below, not by the SwiftUI frame: + // pinning both would demand the full 580 for content *and* leave the scroller + // with nowhere to go, pushing it past the right edge where the window clips it. + // Letting the content flex mirrors the Help window, whose scroller sits inboard. let rootView = PreferencesView() - .frame(minWidth: 580, maxWidth: 580, minHeight: 400, maxHeight: .infinity) + .frame(maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) .ignoresSafeArea() .environmentObject(eyedroppers) let view = NSHostingView(rootView: rootView) @@ -415,6 +444,9 @@ class WindowCoordinator: NSObject { size: NSRect(x: 0, y: 0, width: 580, height: 600), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] ) + // Matches About/Help/Splash — lets the header gradient run up behind the titlebar + // instead of leaving an opaque bar above it. + preferencesWindow?.titlebarAppearsTransparent = true preferencesWindow?.minSize = NSSize(width: 580, height: 400) preferencesWindow?.maxSize = NSSize(width: 580, height: CGFloat.greatestFiniteMagnitude) preferencesWindow?.contentMinSize = NSSize(width: 580, height: 400) @@ -426,19 +458,24 @@ class WindowCoordinator: NSObject { } func openSplashWindow() { - splashWindow = PikaWindow.createSecondaryWindow( - title: PikaText.textAppName, - size: NSRect(x: 0, y: 0, width: 650, height: 380), - styleMask: [.titled, .fullSizeContentView] - ) - // `createSecondaryWindow` derives the autosave name from the title, which for - // the splash ("Pika") collides with the main window's "Pika Window" name and - // would let the transient, always-centered splash pollute the persisted main - // window frame. The splash never needs to remember its position, so clear it. - splashWindow.setFrameAutosaveName("") - splashWindow.titlebarAppearsTransparent = true - splashTouchBarController = SplashTouchBarController(window: splashWindow) - splashWindow.contentView = NSHostingView(rootView: SplashView().ignoresSafeArea()) + if splashWindow == nil { + splashWindow = PikaWindow.createSecondaryWindow( + title: PikaText.textAppName, + // Sized to fit the full setup list without scrolling, including a permission + // step (Grant Screen Recording / Grant Accessibility) when one is shown. + size: NSRect(x: 0, y: 0, width: 720, height: 720), + styleMask: [.titled, .fullSizeContentView] + ) + // `createSecondaryWindow` derives the autosave name from the title, which for + // the splash ("Pika") collides with the main window's "Pika Window" name and + // would let the transient, always-centered splash pollute the persisted main + // window frame. The splash never needs to remember its position, so clear it. + splashWindow.setFrameAutosaveName("") + splashWindow.titlebarAppearsTransparent = true + splashTouchBarController = SplashTouchBarController(window: splashWindow) + splashWindow.contentView = NSHostingView(rootView: SplashView().ignoresSafeArea()) + } + splashWindow.makeKeyAndOrderFront(nil) splashWindow.fadeIn(nil) } } diff --git a/Pika/Views/ColorPickers.swift b/Pika/Views/ColorPickers.swift index aca1f06c..d28c6469 100644 --- a/Pika/Views/ColorPickers.swift +++ b/Pika/Views/ColorPickers.swift @@ -1,8 +1,30 @@ import SwiftUI +/// Natural height of a swatch's readout block, reduced across both swatches with `max`. +/// The taller of the two wins so the boundary hairline sits at one height across the pair +/// rather than stepping where a value happens to wrap onto a second line. +struct ReadoutHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = Swift.max(value, nextValue()) + } +} + struct ColorPickers: View { @EnvironmentObject var eyedroppers: Eyedroppers + /// Shared across both swatches rather than owned per-swatch: a click landing on either + /// swatch's pick target while a colour field is focused needs to dismiss whichever swatch + /// actually owns that field, not necessarily the one that was clicked (the window-wide + /// first-responder check in `EyedropperButton.PickTarget` can't tell them apart on its own). + /// Broadcasting the bump to both is harmless — only the swatch with an active session reacts. + @State private var dismissEditingTrigger: Int = 0 + /// Tallest readout block of the two, applied to both — see `ReadoutHeightKey`. + @State private var readoutHeight: CGFloat = 0 + /// Hovering either swatch shows the boundary on both: it marks where the pair stops being + /// a pick target, which is one fact about the whole row, not a per-swatch one. + @State private var pickersHovered = false + var body: some View { let eyedropperArray: [Eyedropper] = [eyedroppers.foreground, eyedroppers.background] @@ -10,9 +32,21 @@ struct ColorPickers: View { ForEach(Array(eyedropperArray.enumerated()), id: \.element.type) { _, eyedropper in // No divider between the two swatches — the colours meet directly, and the // horizontal section dividers do the framing. - EyedropperItem(eyedropper: eyedropper) + EyedropperItem( + eyedropper: eyedropper, + dismissEditingTrigger: $dismissEditingTrigger, + readoutHeight: readoutHeight, + showsReadoutBoundary: pickersHovered + ) } } + .onPreferenceChange(ReadoutHeightKey.self) { height in + // Deferred: this fires from within the layout pass that measured it, and writing + // state straight back re-enters that pass (the same reentrancy that silently dropped + // the swatch-width measurement in `EditableColorValue`). + DispatchQueue.main.async { readoutHeight = height } + } + .onHover { pickersHovered = $0 } } } diff --git a/Pika/Views/ContentView.swift b/Pika/Views/ContentView.swift index a083b478..4a3d27af 100644 --- a/Pika/Views/ContentView.swift +++ b/Pika/Views/ContentView.swift @@ -6,9 +6,9 @@ import UniformTypeIdentifiers /// Height thresholds (in points of available window content height) below which each /// element is shed, so the window can shrink far smaller than the sum of everything. /// Ordered largest-first to match the shed order: palettes drop first, then contrast, -/// preview, and colour names. Type labels are the last to go, but they hide only via the -/// preview-pill overlap — the window floor (160) sits above any useful height threshold -/// for them. These are the tuning knobs for the adaptive layout. +/// preview, and colour names. Type labels never shed on height — the window floor (160) +/// sits above any useful threshold for them. These are the tuning knobs for the adaptive +/// layout. enum PikaAdaptiveHeight { static let floor: CGFloat = 160 // bare minimum content height (matches window frame min ≈ 200pt window) static let expandCornerBelow: CGFloat = 200 // below this content height (~240pt window) the button tucks top-right @@ -36,6 +36,11 @@ enum PikaAdaptiveWidth { struct PikaAdaptiveVisibility: Equatable { var showsTypeLabels: Bool = true var showsColorNames: Bool = true + /// Width of a single swatch (half the window's content width — the two panels split it + /// evenly with no gap). Read from this outer `GeometryReader`, not measured again lower in + /// the tree: a `GeometryReader`/preference round trip placed inside `EyedropperButton`'s own + /// `ZStack` was found to never fire past its initial (zero) value. + var swatchWidth: CGFloat = 0 } private struct PikaAdaptiveVisibilityKey: EnvironmentKey { @@ -106,8 +111,6 @@ struct ContentView: View { let allowContrastWidth = width >= PikaAdaptiveWidth.contrast let allowContrast = allowContrastHeight && allowContrastWidth let allowPalettes = height >= PikaAdaptiveHeight.palettes && width >= PikaAdaptiveWidth.palettes - // The preview pill overlaps the type labels, so labels only show when the - // pill is effectively hidden. let previewVisible = showColorPreview && allowPreview // An enabled element we're hiding purely for space. When any exist, offer to @@ -194,10 +197,14 @@ struct ContentView: View { } .frame(width: geo.size.width, height: geo.size.height, alignment: .top) .environment(\.pikaAdaptiveVisibility, PikaAdaptiveVisibility( - // Type labels sit behind the preview pill, so they hide only when it shows - // — the window floor keeps height above any threshold that would drop them. - showsTypeLabels: !previewVisible, - showsColorNames: height >= PikaAdaptiveHeight.colorNames + // Not tied to the preview pill: the pill is anchored to the top of the + // swatches and the labels to the bottom, so they don't contend for the same + // space — measured at every height from the pill's own minimum (200) upward, + // the smallest gap between them is still ~30pt. The window floor keeps height + // above any threshold that would drop the labels on their own account. + showsTypeLabels: true, + showsColorNames: height >= PikaAdaptiveHeight.colorNames, + swatchWidth: width / 2 )) // Continuous hover is stable when the button appears under the cursor — // plain `.onHover` re-fires enter/exit as the overlay mounts, which flickers diff --git a/Pika/Views/EditableColorValue.swift b/Pika/Views/EditableColorValue.swift new file mode 100644 index 00000000..4677a8a0 --- /dev/null +++ b/Pika/Views/EditableColorValue.swift @@ -0,0 +1,833 @@ +import AppKit +import Defaults +import SwiftUI + +/// The "⚠ Invalid input" pill shown beside the type label while a focused field holds +/// unparseable input. Coloured to the swatch's UI text colour so it reads on any background. +struct InvalidInputPill: View { + let uiColor: NSColor + + var body: some View { + HStack(spacing: 3) { + Image(systemName: "exclamationmark.triangle.fill") + Text(PikaText.textColorEditInvalid) + } + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(Color(uiColor)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color(uiColor).opacity(0.18))) + } +} + +/// Units nudged per pixel of drag/scroll (or per arrow-key press), scaled to a component's own +/// range so every field's full span takes about the same drag distance to traverse — hue's +/// `0...360` (which felt right at a flat 1 unit/px) is the reference; without this, OKLCH +/// chroma's `0...1` range would swing end-to-end in a single pixel. Unranged components (e.g. +/// Lab a/b) fall back to the flat 1 unit/px, having no span to scale against. +let dragSensitivityReferenceSpan: Double = 360 + +func dragUnitsPerPixel(for range: ClosedRange?) -> Double { + guard let range else { return 1.0 } + return (range.upperBound - range.lowerBound) / dragSensitivityReferenceSpan +} + +/// The editable colour readout. Fixed format scaffolding renders as dimmed, non-editable text; +/// each numeric component is a focusable field. Valid edits preview live on `eyedropper` (which +/// posts nothing, so history stays quiet); Enter or a valid blur commits — recording history once +/// via `.colorPicked` — while Escape or an invalid blur reverts to the pre-edit colour. +struct EditableColorValue: View { + @ObservedObject var eyedropper: Eyedropper + let format: ColorFormat + let style: CopyFormat + let colorSpace: NSColorSpace + /// Width of this swatch (half the window's content width), read from `ContentView`'s own + /// outer `GeometryReader` via `PikaAdaptiveVisibility.swatchWidth` and threaded down through + /// `EyedropperButton`. Not measured again here or in `EyedropperButton`: a `GeometryReader`/ + /// preference round trip placed lower in the tree — around this view's own `FlowLayout`, and + /// separately around `PickTarget`'s frame — was found in both cases to never fire past its + /// initial zero value, so the row's width fell back to an ambiguous `.frame(maxWidth: .infinity)` + /// that was itself sometimes only ever queried for its ideal size, never wrapping. + let availableWidth: CGFloat + /// Raised while the focused field holds unparseable input, so the parent can show the pill. + @Binding var isInvalid: Bool + /// Bumped by the parent to end any active edit session (e.g. a click landing elsewhere in the + /// swatch that's meant to dismiss a focused field rather than act on it). Driving this + /// straight through `focusedIndex` rather than via AppKit's responder chain (e.g. + /// `window.endEditing(for:)`) matters: that resigns the field editor, but `ScrubTextField`'s + /// own `resignFirstResponder` override — the thing that actually reports the blur back up + /// via `onFocusChange` — doesn't reliably fire for it, leaving this view's local state (and + /// its focus outline) stuck showing "focused" even once AppKit itself has moved on. + var dismissEditingTrigger: Int = 0 + + private let baseSize: CGFloat = 18 + private let minSize: CGFloat = 11 + + /// Identifies which (format, style, colorSpace) a cached `values` array was decomposed for, + /// so a format/style switch — or a display colour-space switch in Preferences, which changes + /// `decompose`'s output just as much — is detected even when the component count doesn't + /// change (every non-hex format always has exactly 3 components). + private struct FormatStyleKey: Equatable { + let format: ColorFormat + let style: CopyFormat + let colorSpace: NSColorSpace + } + + /// The outer VStack's `.padding(.all, 10)` plus the trailing gutter this view is given at + /// its call site (`.padding(.trailing, 32)`, reserved for the copy/system-picker hover + /// buttons) — both applied *outside* this view, so `availableWidth` (measured at the swatch + /// content's outer edge) has to have them subtracted back out here. + private let horizontalInset: CGFloat = 52 + private var effectiveWidth: CGFloat { max(availableWidth - horizontalInset, 0) } + + /// Working component strings. Kept in sync with the colour when idle; owned by the user + /// while a field is focused. + @State private var values: [String] = [] + @State private var valuesKey: FormatStyleKey? + @State private var isEditing = false + /// Font size pinned for the duration of a session, so scrubbing (which changes the value's + /// digit count, and so its rendered width, on essentially every frame) doesn't repeatedly + /// re-shrink/re-wrap the row — the flicker that made the value visibly flick between one and + /// two lines while dragging. Captured once at session start, cleared once it ends. + @State private var frozenSize: CGFloat? + @State private var preEditColor: NSColor? + /// The colour we last pushed to `eyedropper` ourselves (live preview or commit). Lets + /// `onChange(of: eyedropper.color)` tell our own writes apart from an external pick landing + /// mid-edit, so the latter can abort the session instead of being silently overwritten. + @State private var lastPreviewedColor: NSColor? + /// Plain `@State`, not `@FocusState`: nothing here is bound via `.focused()` to an actual + /// SwiftUI-focusable view (the field is a raw AppKit `NSTextField`, focus is driven by hand + /// through `ColorComponentField`'s `onFocusChange`). `@FocusState` expects to reconcile + /// against the real focus environment and would get silently reset to `nil` by unrelated + /// window/focus churn — e.g. a sibling's hover state changing — dropping the outline and + /// ending the edit the moment the mouse left the field, even mid-session. + @State private var focusedIndex: Int? + /// Which field the currently-open session (if any) belongs to — always non-nil whenever + /// `isEditing` is true, kept in sync with `focusedIndex` whenever focus moves but, unlike + /// `focusedIndex`, also set for a fresh unfocused scrub session (drag/scroll deliberately + /// never focuses the real field — see `beginDragSession`'s comment). Exists so a scrub + /// session's end — `onDragEnd`, which for scroll-to-scrub can arrive late via trailing + /// trackpad momentum — can tell "this is still my session" apart from "a different field + /// has since taken over, or this session already ended and a new one started elsewhere." + @State private var sessionOwner: Int? + /// The component values as they were when the session began, kept pristine for its whole + /// duration. Every scrub frame recomposes from *these* rather than from `values`, which now + /// tracks the clamped colour: feeding each frame's clamped result back in would make the drag + /// path-dependent, ratcheting the untouched components a little further every frame so + /// dragging back where you came from no longer returns the colour you started with. + @State private var sessionStartValues: [String] = [] + /// The whole colour, formatted, while a scrub is in flight — shown in one pill above the row. + /// Every field's text stays frozen for the gesture: syncing the untouched components live + /// would change *their* widths instead, which moves `FlowLayout`'s wrap point just as surely + /// as the dragged one did. Showing the complete value here keeps the readout honest without + /// anything in the row itself changing size. + @State private var rowScrubPreview: String? + /// True for the duration of a click-drag/scroll scrub. A scrub deliberately resigns first + /// responder (so no caret or selection shows over a value you're dragging), and that blur + /// must not be mistaken for a tab-out that should commit and close the session. + @State private var isScrubbing = false + + private var decomposed: DecomposedColor { + format.decompose(eyedropper.color, style: style, in: colorSpace) + } + + private var uiColor: NSColor { eyedropper.color.getUIColor() } + + /// `FlowLayout` should never need more than this many lines. + private let maxLines: CGFloat = 2 + /// Shrink target, deliberately less than `maxLines`: wrapping happens at fragment boundaries, + /// not the halfway character, so a greedy 2-line wrap rarely splits content 50/50 — leave + /// slack instead of clipping the fuller line. + private let wrapShrinkTarget: CGFloat = 1.7 + + // Deterministic font size vs `wrapShrinkTarget` rows of column width, so the row shrinks + // just enough that `FlowLayout` wraps to at most `maxLines`. + private func fontSize(for text: String) -> CGFloat { + guard effectiveWidth > 4 else { return baseSize } + let full = (text as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: baseSize)]).width + guard full > 0 else { return baseSize } + let scale = min(1, (effectiveWidth * wrapShrinkTarget) / full) + return max(minSize, baseSize * scale) + } + + var body: some View { + let layout = decomposed + let size = frozenSize ?? fontSize(for: layout.joined()) + + FlowLayout(maxLines: Int(maxLines)) { + affix(layout.leading, size: size) + ForEach(Array(layout.components.enumerated()), id: \.offset) { index, component in + // Grouped with its trailing punctuation (the separator after it, or the closing + // affix for the last one) into one atomic wrap unit — otherwise `FlowLayout` + // could wrap that punctuation onto the next line by itself, orphaned ahead of + // the value it actually belongs to. + HStack(spacing: 0) { + ColorComponentField( + text: binding(for: index, layout: layout), + component: component, + index: index, + uiColor: uiColor, + fontSize: size, + focusedIndex: $focusedIndex, + onSubmit: commitEditing, + onCancel: revertEditing, + onDragBegin: { beginDragSession(index: index, layout: layout) }, + onDragEnd: { finishDragOrScrollSession(index: index) }, + onLiveValue: { value, places in + previewLiveScrub(index: index, layout: layout, value: value, decimals: places) + } + ) + if index < layout.separators.count { + affix(layout.separators[index], size: size) + } else { + affix(layout.trailing, size: size) + } + } + } + } + // Decorative overlay: it doesn't feed into the row's reported size, so it can appear and + // change width without perturbing `FlowLayout`. Anchored to the row rather than to the + // dragged field, so it's always in bounds and doesn't jump between components. + .overlay(alignment: .topLeading) { + if let rowScrubPreview { + Text(rowScrubPreview) + // Monospaced so the digits hold their columns: at a proportional width the + // numbers jitter sideways on every frame of a drag, which is exactly the + // distraction the pill exists to avoid. + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .foregroundStyle(Color(uiColor == .white ? .black : .white)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Color(uiColor).opacity(0.92))) + .frame(maxWidth: effectiveWidth, alignment: .leading) + // The swatch's content carries a text shadow for legibility on any colour; + // inherited by the pill it just reads as blur, so cancel it here. + .shadow(color: .clear, radius: 0, x: 0, y: 0) + .offset(y: -24) + .allowsHitTesting(false) + } + } + // An explicit width, not `.frame(maxWidth: .infinity)`: a plain flexible frame was found + // to sometimes only ever be queried for its *ideal* size in this view's position in the + // hierarchy, never its true constrained size, so `FlowLayout` never wrapped and the row + // silently overflowed past the window edge instead. `availableWidth` comes from + // `ContentView`'s own outer `GeometryReader` (see its doc comment), so it's already + // non-zero on this view's very first render — no separate zero-width bootstrap state + // to fall back from. + .frame(width: effectiveWidth, alignment: .leading) + .onAppear { syncValuesFromColor(layout) } + .onChange(of: focusedIndex) { newValue in handleFocusChange(to: newValue, layout: layout) } + .onChange(of: eyedropper.color) { newValue in + if isEditing { + // A change we didn't push ourselves is an external pick landing mid-edit — + // abort the session so the external colour wins, matching pre-edit behaviour. + if newValue != lastPreviewedColor { abortEditingForExternalPick() } + } else if newValue != lastPreviewedColor { + // Only resync from a colour we didn't just set ourselves. Without this check, + // this handler fires right after our own commit lands (isEditing has already + // flipped false by then) and undoes finishEditing's deliberate `resync: false` — + // e.g. snapping a just-typed hue back to 0 once brightness/saturation round-trips + // it through a colour where hue is undefined. + syncValuesFromColor(decomposed) + } + } + .onChange(of: format) { _ in handleFormatOrStyleChange() } + .onChange(of: style) { _ in handleFormatOrStyleChange() } + .onChange(of: colorSpace) { _ in handleFormatOrStyleChange() } + .onChange(of: dismissEditingTrigger) { _ in focusedIndex = nil } + } + + private func affix(_ text: String, size: CGFloat) -> some View { + Text(text) + .font(.system(size: size, weight: .regular)) + .foregroundStyle(Color(uiColor).opacity(0.5)) + .lineLimit(1) + } + + // MARK: - Values ↔ colour + + private func syncValuesFromColor(_ layout: DecomposedColor) { + values = layout.values + valuesKey = FormatStyleKey(format: format, style: style, colorSpace: colorSpace) + } + + private func binding(for index: Int, layout: DecomposedColor) -> Binding { + let currentKey = FormatStyleKey(format: format, style: style, colorSpace: colorSpace) + return Binding( + get: { + guard valuesKey == currentKey, index < values.count else { return layout.values[index] } + return values[index] + }, + set: { newValue in + if valuesKey != currentKey || values.count != layout.components.count { + values = layout.values + valuesKey = currentKey + } + guard index < values.count else { return } + values[index] = newValue + // A scrub's own end (`ScrubTextField.onDragEnd`/`handleScrollEnded`) writes here + // once, only to unfreeze the field's displayed text — not to request a new + // colour. `values` at that point still holds the *other* components' stale + // session-start numbers (only `index` was just updated), so recomposing here + // would clobber `eyedropper.color`, which `previewLiveScrub` already kept correct + // every frame, with a wrong colour right before `finishEditing`'s + // `commitLiveScrubColor` commits it. + guard !isScrubbing else { return } + previewIfValid(layout: layout) + } + ) + } + + // MARK: - Editing lifecycle + + /// A format, copy-style, or display colour-space switch changes how the same colour is + /// *displayed*, not the colour itself. Mid-edit, the typed values are in the old units and + /// can't be reinterpreted safely, so abort the session rather than risk a bogus commit; + /// otherwise just resync. + private func handleFormatOrStyleChange() { + if isEditing { + abortEditingForExternalPick() + } else { + syncValuesFromColor(decomposed) + } + } + + /// Starting a drag on a field that isn't the one currently owning the edit session (if any) + /// joins that session by moving focus to it, the same way clicking a new field mid-edit does + /// in `handleFocusChange` — rather than letting a second, session-less drag mutate the shared + /// `values` array and then tear down the first field's session on release. + /// + /// A *fresh* session deliberately does NOT set `focusedIndex`: scrubbing (drag or scroll) + /// must never focus the real `TextField`, or its AppKit field editor becomes first responder + /// and fights the scrub with click-to-edit/select-all behaviour. An unfocused `TextField` + /// with a changing `text` binding just renders like a label — no editor involved. + private func beginDragSession(index: Int, layout: DecomposedColor) { + sessionOwner = index + isScrubbing = true + if isEditing { + focusedIndex = index + rebudgetFrozenSize(for: index, layout: layout) + return + } + startSession(index: index, layout: layout) + } + + private func handleFocusChange(to newValue: Int?, layout: DecomposedColor) { + if let newValue { + // Entering (or moving between) fields — start a session on the first focus, or + // (per the comment on `sessionOwner`) take over an existing unfocused scrub session. + sessionOwner = newValue + if !isEditing { + startSession(index: newValue, layout: layout) + } else { + // Moving to a different field within an already-open session (e.g. Tab) — + // `frozenSize` was budgeted for the *previous* field's worst case, so it must be + // re-budgeted for this one or a longer value typed here has nowhere to grow. + rebudgetFrozenSize(for: newValue, layout: layout) + } + } else if isEditing, !isScrubbing { + // Focus left every field (blur / tab-out) — commit if valid, otherwise revert. + // Not during a scrub: that blur is one we asked for, not the user leaving the field. + finishEditing() + } + } + + /// `onDragEnd` for click-drag scrub is driven by a synchronous, blocking event-tracking loop + /// (`ScrubTextField.mouseDown`), so it can never fire late — nothing else can run until it + /// returns. Scroll-to-scrub's end can, though: trailing trackpad momentum can deliver + /// `onScrollEnd` well after a different field has taken over the session (a plain click + /// focusing it, or a fresh drag/scroll on it), or after this session already ended and a new + /// one started elsewhere. Only finish if `index` is still the session's current owner. + private func finishDragOrScrollSession(index: Int) { + guard isEditing, sessionOwner == index else { return } + rowScrubPreview = nil + isScrubbing = false + finishEditing(commitLiveScrubColor: true) + // A scrub's committed colour is the clamped, displayable one, which may not decompose + // back to exactly the values that produced it. Resync the whole readout from the real + // colour so what's shown is what's on screen — otherwise the next interaction resyncs + // instead, and the values appear to change on their own. + syncValuesFromColor(decomposed) + // AppKit implicitly focuses (and select-alls) a field as part of routing the mouseDown + // that turns out to be a click-drag (see `ScrubTextField.beginDrag`'s comment) — + // regardless of whether the drag started fresh or on an already-focused field. A scrub + // must never leave the field looking like an active text edit once it's done (the + // "renders like a label" intent documented on `beginDragSession` above); `finishEditing`/ + // `endSession` only manage `isEditing`, not `focusedIndex`. Clearing it here — rather + // than resigning first responder directly from AppKit — routes through the same + // `updateNSView` reconciliation (`isFocused`/`editorIsActive`) that already reliably + // drives real focus changes elsewhere, instead of depending on `resignFirstResponder` + // firing for a resign that didn't originate from `self` becoming first responder, which + // proved unreliable (see `PickTarget`'s `dismissEditingTrigger`). + if focusedIndex == index { + focusedIndex = nil + } + } + + /// Snapshot the colour and working values at the start of an edit or drag session, so + /// `finishEditing`/`abortEditingForExternalPick` have a consistent point to commit or revert to. + private func startSession(index: Int, layout: DecomposedColor) { + isEditing = true + sessionStartValues = layout.values + // Sized to every field's current displayed width, except `index` — the one about to + // become editable — which is sized to its own worst case. Budgeting the *whole* row for + // worst case (the pre-f3cca6b behaviour) made the readout collapse to `minSize` the + // instant you clicked it in a narrow window, since every other field paid for width it + // never needed. But budgeting only the current width regressed the opposite way: typing + // a longer in-range value (e.g. hue "5" -> "355") has nowhere to grow, since nothing else + // in the row changes width mid-scrub any more (every field's text is frozen and the live + // value goes to the pill), only `index`'s field is ever actually typed into during this + // session, so it's the only one that needs worst-case headroom. + rebudgetFrozenSize(for: index, layout: layout) + preEditColor = eyedropper.color + values = layout.values + valuesKey = FormatStyleKey(format: format, style: style, colorSpace: colorSpace) + } + + /// Budgets `frozenSize` for whichever field is about to become editable — worst-case width + /// for `index`, current displayed width for every other field (see the comment on the call + /// site in `startSession`). Called both when a session opens and whenever the editable field + /// changes mid-session (Tab, or a drag/click landing on a different field), since the frozen + /// budget from the field that opened the session doesn't cover a value typed into a later one. + private func rebudgetFrozenSize(for index: Int, layout: DecomposedColor) { + frozenSize = fontSize(for: boundedWorstCaseJoined(layout, growingIndex: index)) + } + + /// Same scaffolding as `layout.joined()`, but the component at `growingIndex` is replaced + /// with its own worst-case placeholder (see `worstCaseComponentString`) — that's the only + /// field a typed edit can actually grow during this session. + private func boundedWorstCaseJoined(_ layout: DecomposedColor, growingIndex: Int) -> String { + var result = layout.leading + for (index, component) in layout.components.enumerated() { + result += index == growingIndex ? worstCaseComponentString(component) : component.value + if index < layout.separators.count { result += layout.separators[index] } + } + return result + layout.trailing + } + + /// The widest value a component could ever display. Integers use the range's most digits; + /// decimals use the range's most integer-part digits plus 4 decimal places (the original + /// stripped format's max — still the true worst case even though scrubbing now defaults to + /// coarser 2-place rounding, since finer starting precision is preserved up to 4). A leading + /// "-" is budgeted for any component whose range allows (or has no range, e.g. Lab a/b) a + /// negative value. Unranged decimals (Lab a/b) have no clamp and are genuinely unbounded, so + /// there's no true worst case to size to; 3 int digits is a practical bound that covers real + /// sRGB-gamut a*/b* extremes (b* reaches roughly -107) without reserving excessive width. + /// Hex is already fixed-length, so it's left as-is. + private func worstCaseComponentString(_ component: ColorComponent) -> String { + let sign = (component.range?.lowerBound ?? -1) < 0 ? "-" : "" + switch component.kind { + case .hex: + return component.value + case .integer: + let digits = component.range.map { + max(String(abs(Int($0.upperBound.rounded()))).count, String(abs(Int($0.lowerBound.rounded()))).count) + } ?? 3 + return sign + String(repeating: "9", count: max(digits, 1)) + case .decimal: + let intDigits = component.range.map { + max(String(abs(Int($0.upperBound))).count, String(abs(Int($0.lowerBound))).count) + } ?? 3 + return sign + String(repeating: "9", count: max(intDigits, 1)) + "." + String(repeating: "9", count: 4) + } + } + + /// Recompose the working values and preview them live; flag invalid input for the pill. + private func previewIfValid(layout: DecomposedColor) { + let allValid = zip(layout.components, values).allSatisfy { $0.isValid($1) } + isInvalid = !allValid + guard allValid, let color = format.recompose(values, style: style, in: colorSpace) else { return } + eyedropper.set(color) + lastPreviewedColor = eyedropper.color + } + + /// Returns the value actually achieved — which is not always the one requested. Lab/OKLCH can + /// express colours outside sRGB, and `recompose` clamps those to the nearest displayable + /// channel (see `NSColor.encodeSRGB`), so e.g. `oklch(30% 0.2 230)` really lands on chroma + /// ~0.137. Reading the value back off the resulting colour means the readout can only ever + /// show a colour the screen can genuinely produce: a drag past the gamut boundary simply + /// stops there instead of displaying a number that silently disagrees with the swatch (and + /// then appearing to "jump" when a later interaction resynced from the real colour). + @discardableResult + private func previewLiveScrub(index: Int, layout: DecomposedColor, value: Double, decimals: Int) -> Double { + guard index < layout.components.count else { return value } + // From the session's starting values, never the live (clamped) ones — see + // `sessionStartValues`. This is what makes a scrub reversible: drag chroma up into the + // clamped region and back down, and you land on exactly the colour you began with. + var liveValues = sessionStartValues.count == layout.components.count ? sessionStartValues : layout.values + guard index < liveValues.count else { return value } + liveValues[index] = ColorComponentField.formattedDragValue(value, kind: layout.components[index].kind) + guard let color = format.recompose(liveValues, style: style, in: colorSpace) else { return value } + eyedropper.set(color) + lastPreviewedColor = eyedropper.color + // Round-trip the committed colour back through `decompose` to see what it actually + // became. Clamping doesn't only move the dragged component — pushing chroma out of gamut + // shifts the resulting colour's lightness and hue too — so resync every *other* component + // from the real colour. Without this the readout contradicts the swatch (a magenta swatch + // still showing a blue hue), and worse, the commit would pair the dragged component's new + // value with the others' stale ones and land on a third colour entirely. + // + // The dragged component itself is deliberately left alone: its text stays frozen for the + // gesture so the row can't reflow, and the pill shows its live value instead. + let achieved = format.decompose(color, style: style, in: colorSpace) + guard index < achieved.components.count, + let effective = Double(achieved.components[index].value.trimmingCharacters(in: .whitespaces)) + else { + return value + } + // Just the numbers — the `oklch(`/`)` scaffolding is already right there in the row + // beneath, so repeating it in the pill is noise. The dragged component renders at the + // scrub's own precision (what vertical travel is adjusting); the rest show as decomposed. + var preview = "" + for (position, component) in achieved.components.enumerated() { + preview += position == index + ? ColorComponentField.formattedDragValue(effective, kind: component.kind, stableDecimalPlaces: decimals) + : component.value + if position < achieved.separators.count { preview += achieved.separators[position] } + } + rowScrubPreview = preview + return effective + } + + /// Snaps any component whose typed value fell outside its range to the nearest bound, and + /// restrips every numeric value's trailing zeros back to its normal compact form — undoing + /// the fixed-decimal-places padding a scrub session keeps live (see `formattedDragValue`) now + /// that it's ending. Hex is skipped explicitly: an all-decimal-digit hex string (e.g. + /// `000000`) parses fine as a `Double`, and `formattedDragValue`'s `.hex` case always returns + /// `""`, which would blank the field. + private func finalizeValues(layout: DecomposedColor) { + for (i, component) in layout.components.enumerated() where i < values.count { + guard component.kind != .hex, + let n = Double(values[i].trimmingCharacters(in: .whitespaces)) else { continue } + let clamped = component.range.map { min(max(n, $0.lowerBound), $0.upperBound) } ?? n + values[i] = ColorComponentField.formattedDragValue(clamped, kind: component.kind) + } + } + + private func commitEditing() { + // Called on Return: drop focus, which routes through finishEditing(). + focusedIndex = nil + } + + private func finishEditing(commitLiveScrubColor: Bool = false) { + let layout = decomposed + // A scrub (click-drag or scroll) has already committed the live, gamut-clamped colour to + // `eyedropper.color` on every frame via `previewLiveScrub`. Recomposing from `values` here + // would pair the dragged component's achieved value with the *other* components' stale + // session-start values — a colour the user never saw (see `previewLiveScrub`'s comment on + // out-of-gamut clamping shifting every component). Commit the already-correct live colour + // directly instead; the caller resyncs the readout from it right after. + if commitLiveScrubColor { + lastPreviewedColor = eyedropper.color + NotificationCenter.default.post(name: .colorPicked, object: nil) + endSession(resync: false) + return + } + let allValid = zip(layout.components, values).allSatisfy { $0.isValid($1) } + if allValid, let color = format.recompose(values, style: style, in: colorSpace) { + eyedropper.set(color) + lastPreviewedColor = eyedropper.color + NotificationCenter.default.post(name: .colorPicked, object: nil) + // `recompose` already clamps an out-of-range number rather than rejecting it (see + // `ColorComponent.isValid`); snap the displayed text to match what was actually + // used, rather than leaving e.g. a typed "400" showing next to a hue that's + // actually 360. + finalizeValues(layout: layout) + // Don't otherwise resync `values` from the just-committed colour: some formats are + // lossy at their extremes (e.g. HSB hue/saturation are undefined at brightness 0), + // so decomposing straight back can silently discard what was just typed — e.g. + // typing hsb(0, 50%, 0%) round-trips through black and reports back 0% saturation. + // `values` already holds exactly what was committed, which is the more faithful + // thing to show. + endSession(resync: false) + } else if let preEditColor { + eyedropper.set(preEditColor) + lastPreviewedColor = eyedropper.color + endSession(resync: true) + } else { + endSession(resync: true) + } + } + + private func revertEditing() { + if let preEditColor { + lastPreviewedColor = preEditColor + eyedropper.set(preEditColor) + } + focusedIndex = nil + endSession(resync: true) + } + + /// An external eyedropper pick landed while a field was focused — abort the edit so the + /// pick wins, rather than letting a later blur silently overwrite it with typed values. + private func abortEditingForExternalPick() { + focusedIndex = nil + endSession(resync: true) + } + + private func endSession(resync: Bool) { + isEditing = false + isScrubbing = false + rowScrubPreview = nil + frozenSize = nil + isInvalid = false + preEditColor = nil + sessionOwner = nil + // Only clear the own-write marker when we're about to resync anyway. A `resync: false` + // commit (see `finishEditing`'s success path) deliberately keeps `values` as typed rather + // than the freshly (and possibly lossily) decomposed colour — clearing this unconditionally + // made the very next `onChange(of: eyedropper.color)` pass see `newValue != nil` and + // resync anyway, undoing that on the next render and silently discarding what was just + // committed (e.g. hue snapping back to 0 once brightness/saturation round-trip through it). + if resync { + lastPreviewedColor = nil + syncValuesFromColor(decomposed) + } + } +} + +/// A single focusable numeric/hex field styled to the swatch's UI colour, with the four design +/// states: default (bare), hover (filled), focus (outlined), invalid (dashed outline). +/// +/// Backed by a custom `NSTextField` (`ScrubTextField`, in `ScrubTextField.swift`) rather than a plain SwiftUI +/// `TextField`. Three attempts to bolt click-drag-to-scrub onto a native `TextField` via +/// SwiftUI `DragGesture`/local event monitors all lost the race against AppKit's own +/// click-to-focus — a raw mouseDown on a real `TextField` always focuses/selects it immediately, +/// before any gesture recognizer gets a chance to see the drag. Owning `mouseDown` directly is +/// the only way to decide click-vs-drag *before* anything focuses. +struct ColorComponentField: View { + @Binding var text: String + let component: ColorComponent + let index: Int + let uiColor: NSColor + let fontSize: CGFloat + @Binding var focusedIndex: Int? + let onSubmit: () -> Void + let onCancel: () -> Void + /// Fired when a drag-to-scrub gesture starts/ends, so the parent can wrap it in the same + /// live-preview/commit session used for typed edits (one history entry per drag, not per pixel). + let onDragBegin: () -> Void + let onDragEnd: () -> Void + /// Fired with the raw live value on every drag/scroll step, so the parent can preview the + /// eyedropper colour without touching `text` (which stays frozen for the gesture — see + /// `rowScrubPreview`). + let onLiveValue: (Double, Int) -> Double + + @State private var isHovering = false + /// Non-nil while a two-finger scroll-to-scrub gesture owns this field; holds the value at + /// scroll start. `scrollAccumulated` tracks total vertical scroll since then. + @State private var scrollOrigin: Double? + @State private var scrollAccumulated: CGFloat = 0 + /// The last value computed during an active scroll — nil once no scroll is in progress. + /// Committed to `text` in `handleScrollEnded`, since the field's own text stays frozen + /// (see `EditableColorValue.rowScrubPreview`) for the live-updating part of the gesture. + @State private var scrollLastValue: Double? + /// Decimal places to hold this scroll session's live display at — captured once at scroll + /// start (see `stableDecimalPlaces(for:)`) and held fixed for the session, same reasoning as + /// `ScrubTextField.dragDecimalPlaces`. + @State private var scrollDecimalPlaces = 2 + /// Non-nil while a click-drag or scroll scrub is live: the value the field's own text stays + /// completely frozen against (no re-shrink/re-wrap risk, since nothing about the row's text + /// changes for the rest of the session) is instead shown here, in a floating pill above the + /// field — a value's own rendered width still isn't perfectly stable digit-for-digit even at + /// a fixed decimal-place count (e.g. "0.0000" vs "0.1111" in a proportional font), so a + /// preview that doesn't participate in `FlowLayout`'s sizing at all is the only way to fully + /// rule out wrap flicker while scrubbing. + /// Bumped on every focus event (begin or end) this field reports; see the deferred-blur + /// comment at its use in `onFocusChange` below. + @State private var focusVersion = 0 + + private var isFocused: Bool { focusedIndex == index } + private var isInvalid: Bool { isFocused && !component.isValid(text) } + /// Hex is a single opaque string with no natural min/max to scrub between. + private var isDraggable: Bool { component.kind != .hex } + + var body: some View { + ScrubbableColorField( + text: $text, + fontSize: fontSize, + textColor: uiColor, + isDraggable: isDraggable, + range: component.range, + kind: component.kind, + isFocused: isFocused, + onFocusChange: { focused in + // AppKit doesn't always report a clean single "begin": both a direct + // field-to-field focus move (old field resigns before the new one becomes first + // responder) *and*, it turns out, a field gaining focus from nothing at all + // (its own internal resign/become choreography when a click lands on it) can + // report a spurious "end" immediately before — or, in the from-nothing case, + // interleaved with — the real "begin". Reacting to an "end" synchronously would + // tear the whole edit session down for a frame (dropping the outline, ending + // editing) only to immediately restart it — or, worse, incorrectly cancel a + // "begin" for the very same field that arrives a moment later. Defer the "end" + // one runloop turn and only apply it if nothing else has touched focus since: + // `focusVersion` is bumped on every focus event, so a later event (for this + // field or another) invalidates a stale deferred "end". + focusVersion += 1 + if focused { + focusedIndex = index + } else { + let expectedVersion = focusVersion + DispatchQueue.main.async { + guard focusVersion == expectedVersion, focusedIndex == index else { return } + focusedIndex = nil + } + } + }, + onSubmit: onSubmit, + onCancel: onCancel, + onDragBegin: onDragBegin, + onDragEnd: onDragEnd, + onDragCancel: onCancel, + onLiveValue: onLiveValue, + onStep: isDraggable ? stepValue : nil + ) + .fixedSize() + .padding(.horizontal, 1) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color(uiColor).opacity(isHovering && !isFocused ? 0.18 : 0)) + ) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder( + Color(uiColor).opacity(isFocused ? 0.9 : 0), + style: StrokeStyle(lineWidth: 1, dash: isInvalid ? [2, 2] : []) + ) + ) + .contentShape(Rectangle()) + .onHover { hovering in + isHovering = hovering + guard !isFocused else { return } + // Only show the scrub cursor where scrubbing is actually possible; hex has no + // natural min/max to scrub between, so it keeps the ordinary text cursor. + if hovering { (isDraggable ? NSCursor.resizeLeftRight : NSCursor.iBeam).set() } else { NSCursor.arrow.set() } + } + .animation(.easeInOut(duration: 0.12), value: isHovering) + .animation(.easeInOut(duration: 0.12), value: isFocused) + .background( + Group { + if isDraggable { + ScrollValueAdapter( + isEnabled: !isFocused, + onScroll: handleScrollDelta, + onScrollEnd: handleScrollEnded + ) + } + } + .allowsHitTesting(false) + ) + } + + /// Up/Down arrow keys nudge the focused field by one `dragUnitsPerPixel` step (a tenth of + /// that with Option), same as a single step of click-drag or scroll scrubbing — applied + /// straight to the live-preview binding since the field is already mid-edit (it has to be + /// focused to receive the key at all). + private func stepValue(_ direction: CGFloat) { + let current = Double(text.trimmingCharacters(in: .whitespaces)) ?? 0 + let fine = NSEvent.modifierFlags.contains(.option) + let unitsPerStep = dragUnitsPerPixel(for: component.range) + var newValue = current + Double(direction) * unitsPerStep * (fine ? 0.1 : 1.0) + if let range = component.range { + newValue = min(max(newValue, range.lowerBound), range.upperBound) + } + let places = Self.stableDecimalPlaces(for: text) + text = Self.formattedDragValue(newValue, kind: component.kind, stableDecimalPlaces: places) + } + + /// Two-finger trackpad scroll nudges the value the same way click-drag does: accumulated + /// vertical scroll maps to `dragUnitsPerPixel` units per point (a tenth of that while + /// holding Option), clamped to range. + private func handleScrollDelta(_ deltaY: CGFloat) { + guard isDraggable else { return } + if scrollOrigin == nil { + scrollOrigin = Double(text.trimmingCharacters(in: .whitespaces)) ?? 0 + scrollAccumulated = 0 + scrollDecimalPlaces = Self.initialScrubDecimalPlaces(forRange: component.range, text: text) + onDragBegin() + } + guard let origin = scrollOrigin else { return } + // Inverted: scrolling up (negative deltaY) increases the value, matching the direction + // users expect when nudging a number via a scroll gesture. + scrollAccumulated -= deltaY + let fine = NSEvent.modifierFlags.contains(.option) + let unitsPerStep = dragUnitsPerPixel(for: component.range) + var newValue = origin + Double(scrollAccumulated) * unitsPerStep * (fine ? 0.1 : 1.0) + if let range = component.range { + newValue = min(max(newValue, range.lowerBound), range.upperBound) + } + let achieved = onLiveValue(newValue, scrollDecimalPlaces) + scrollLastValue = achieved + } + + private func handleScrollEnded() { + guard scrollOrigin != nil else { return } + if let scrollLastValue { + text = Self.formattedDragValue(scrollLastValue, kind: component.kind, stableDecimalPlaces: scrollDecimalPlaces) + } + scrollOrigin = nil + scrollAccumulated = 0 + scrollLastValue = nil + onDragEnd() + } + + /// A non-nil `stableDecimalPlaces` rounds every decimal to that many fixed places instead of + /// the usual zero-stripped up-to-4 (e.g. "0.22" rather than "0.2200" or "0.2263") — pass it + /// while a scrub session is live: the zero-stripped form's length changes with the value, + /// which shifts `FlowLayout`'s wrap points on essentially every frame of the drag. Fixed + /// places holds that length steady; see `stableDecimalPlaces(for:)` for how many. The caller + /// restrips to the normal compact form once the session ends (`finalizeValues`). + static func formattedDragValue(_ value: Double, kind: ComponentKind, stableDecimalPlaces: Int? = nil) -> String { + switch kind { + case .hex: + return "" + case .integer: + return String(Int(value.rounded())) + case .decimal: + guard let places = stableDecimalPlaces else { + return CGFloat(value).strippedDecimalString(maxDecimalPlaces: 4) + } + return String(format: "%.\(places)f", value) + } + } + + /// Decimal places at which this component's per-pixel drag step is actually visible: the + /// step is range-scaled (`dragUnitsPerPixel`), so a fixed 2 places leaves a fine-ranged + /// component like OKLCH chroma advancing its last digit only every ~4px — which reads as the + /// number being stuck while the colour plainly changes. Derived from the step so the last + /// digit always moves about once per pixel. + static func naturalDecimalPlaces(forRange range: ClosedRange?) -> Int { + let step = dragUnitsPerPixel(for: range) + guard step > 0 else { return 2 } + return max(0, Int(ceil(-log10(step)))) + } + + /// Decimal places for a scrub session's live display: 2 by default — finer than that isn't a + /// meaningful step to scrub by (0.0001 of a 0...1 range is imperceptible per pixel) — unless + /// the value already displays with more precision than that, in which case keep it. Otherwise + /// starting a scrub would itself immediately truncate the value and reflow the row, before + /// any actual dragging has happened. + static func stableDecimalPlaces(for text: String) -> Int { + guard let dotIndex = text.firstIndex(of: ".") else { return 2 } + let decimals = text.distance(from: text.index(after: dotIndex), to: text.endIndex) + return max(2, min(4, decimals)) + } + + /// Decimal places to start a scrub session at: whichever is finer, the precision this + /// component's drag step can actually resolve (`naturalDecimalPlaces`) or the precision + /// already on display (`stableDecimalPlaces`, so starting a scrub never truncates what's + /// shown), capped at `ScrubTextField.precisionRange`'s ceiling. Shared by click-drag + /// (`ScrubTextField.beginDrag`) and scroll-to-scrub (`handleScrollDelta`) so the two paths + /// can't drift out of sync the way their digit-budget math already has once. + static func initialScrubDecimalPlaces(forRange range: ClosedRange?, text: String) -> Int { + min( + ScrubTextField.precisionRange.upperBound, + max(naturalDecimalPlaces(forRange: range), stableDecimalPlaces(for: text)) + ) + } +} diff --git a/Pika/Views/EyedropperButton.swift b/Pika/Views/EyedropperButton.swift index 836e815f..3398b6ec 100644 --- a/Pika/Views/EyedropperButton.swift +++ b/Pika/Views/EyedropperButton.swift @@ -1,52 +1,100 @@ +import AppKit import Defaults import SwiftUI -private struct ValueWidthKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } -} +/// The full-size background pick target: owns its own `mouseDown` (mirroring `ScrubTextField`'s +/// approach in `EditableColorValue.swift`) so it can check the window's first responder *before* +/// deciding what a click means. A plain SwiftUI `Button` can't make that distinction — its action +/// fires unconditionally on tap, so a click intended to dismiss a focused colour-value field would +/// also fall through and start a new pick. If a field is currently being edited, this click's only +/// job is to end that edit (matching standard click-away-to-blur behaviour); otherwise it starts +/// a pick, same as before. +private struct PickTarget: NSViewRepresentable { + let onPick: () -> Void + let onPressChange: (Bool) -> Void + /// Called instead of `onPick` when the click's only job is to end an active edit session. + /// AppKit's own `endEditing(for:)` resigns the field editor, but doesn't reliably notify + /// `EditableColorValue`'s own focus-tracking state back up (its outline stays stuck showing + /// "focused") — so the parent also needs an explicit nudge to clear that state itself. + let onDismissEditing: () -> Void -/// The colour value, shrunk to fit two lines as its column narrows. The font size is -/// computed deterministically from the (font-independent) column width, so — unlike -/// `minimumScaleFactor` + `lineLimit` — the wrap can't oscillate between one and two -/// lines during a resize. -struct AdaptiveValueText: View { - let value: String - let color: Color - private let baseSize: CGFloat = 18 - private let minSize: CGFloat = 11 - @State private var width: CGFloat = 0 - - private var fontSize: CGFloat { - guard width > 4 else { return baseSize } - let full = (value as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: baseSize)]).width - guard full > 0 else { return baseSize } - // Scale the font *proportionally* with the column width so the wrap point stays - // put as the window resizes — no bistable jumping. The 1.5 factor (vs a - // theoretical 2 for two full lines) leaves slack for word-boundary wrapping, so - // long values still fit two lines instead of spilling to a truncated third. - let scale = min(1, (1.5 * width) / full) - return max(minSize, baseSize * scale) + func makeNSView(context _: Context) -> PickTargetView { + let view = PickTargetView() + view.onPick = onPick + view.onPressChange = onPressChange + view.onDismissEditing = onDismissEditing + return view } - var body: some View { - Text(value) - .foregroundStyle(color) - .font(.system(size: fontSize, weight: .regular)) - .lineLimit(2) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - GeometryReader { geo in - Color.clear.preference(key: ValueWidthKey.self, value: geo.size.width) + func updateNSView(_ view: PickTargetView, context _: Context) { + view.onPick = onPick + view.onPressChange = onPressChange + view.onDismissEditing = onDismissEditing + } + + final class PickTargetView: NSView { + var onPick: (() -> Void)? + var onPressChange: ((Bool) -> Void)? + var onDismissEditing: (() -> Void)? + + override func mouseDown(with _: NSEvent) { + if window?.firstResponder is NSText { + window?.endEditing(for: nil) + onDismissEditing?() + return + } + + onPressChange?(true) + while true { + guard let next = NSApp.nextEvent( + matching: [.leftMouseDragged, .leftMouseUp], + until: .distantFuture, + inMode: .eventTracking, + dequeue: true + ) else { + onPressChange?(false) + return } - ) - .onPreferenceChange(ValueWidthKey.self) { width = $0 } + if next.type == .leftMouseUp { + let point = convert(next.locationInWindow, from: nil) + onPressChange?(false) + if bounds.contains(point) { onPick?() } + return + } + } + } + } +} + +/// Swallows clicks over the readout block so they don't fall through to `PickTarget` and start +/// a screen pick. An AppKit view rather than a SwiftUI `contentShape`: `PickTarget` is itself an +/// `NSView` sitting in the same z-order, so the thing shadowing it has to win AppKit's own +/// hit-testing, not just SwiftUI's. +private struct ClickShield: NSViewRepresentable { + func makeNSView(context _: Context) -> NSView { ShieldView() } + func updateNSView(_: NSView, context _: Context) {} + + final class ShieldView: NSView { + // Absorb rather than forward: the readout's own fields sit in front of this and keep + // receiving their clicks, but the labels, colour name, and the gaps between them no + // longer act as a pick target. + override func mouseDown(with _: NSEvent) {} } } struct EyedropperButton: View { @ObservedObject var eyedropper: Eyedropper + /// Shared with the other swatch (owned by `ColorPickers`) rather than local: a click on + /// *this* swatch's `PickTarget` can be dismissing a field focused on the *other* swatch, + /// since the first-responder check that decides "dismiss vs. pick" is window-wide, not + /// scoped to this button. Bumping a trigger only this button's own `EditableColorValue` + /// hears would silently drop that edit instead of committing or reverting it. + @Binding var dismissEditingTrigger: Int + /// Height to hold the readout block at — the taller of the two swatches', resolved by + /// `ColorPickers`, so the boundary sits at one height across the pair. 0 until measured. + var readoutHeight: CGFloat = 0 + /// Driven by hovering either swatch, so both boundaries show together. + var showsReadoutBoundary: Bool = false @Default(.colorFormat) var colorFormat @Default(.copyFormat) var copyFormat @Default(.hideColorNames) var hideColorNames @@ -57,60 +105,152 @@ struct EyedropperButton: View { @State private var colorSpace = Defaults[.colorSpace] @State private var hoverTask: Task? @State private var childHovered: Bool = false + @State private var valueInvalid: Bool = false + @State private var isPressed: Bool = false + @State private var flashOpacity: Double = 0 + /// Mirrors `wantsColorName`, but only ever changed inside `withAnimation`. Animating the + /// environment value directly doesn't work: it changes as part of the geometry pass that + /// re-evaluates the whole tree, and `.animation(_:value:)` doesn't catch that — the row just + /// snapped to full height. Driving an explicit state change is what makes it a transition. + @State private var colorNameVisible = false + + private var wantsColorName: Bool { !hideColorNames && adaptive.showsColorNames } + + private let colorNameFontSize: CGFloat = 12 + /// Gap between the value and the colour name, carried by the name's own row so it + /// collapses along with it. + private static let colorNameSpacing: CGFloat = 6 + /// The height that row occupies when shown: one line of its own font, plus that gap. + /// Measured from the font rather than hard-coded so it tracks the text it's reserving for. + private var colorNameRowHeight: CGFloat { + let font = NSFont.systemFont(ofSize: colorNameFontSize, weight: .medium) + return ceil(font.ascender - font.descender + font.leading) + Self.colorNameSpacing + } var body: some View { ZStack { - Button(action: { - NSApp.sendAction(eyedropper.type.pickSelector, to: nil, from: nil) - }, label: { - ZStack { - VStack(alignment: .leading, spacing: 2.0) { - // Visibility is size-aware (`adaptive.showsTypeLabels` already - // folds in the preview-pill overlap) so labels fade out as the - // window shrinks and return when it grows again. - let showsTypeLabel = adaptive.showsTypeLabels - Text(eyedropper.type.description) - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(eyedropper.color.getUIColor().opacity(0.75)) - .opacity(showsTypeLabel ? 1 : 0) - .animation( - showsTypeLabel - ? .easeInOut(duration: 0.25).delay(0.3) - : .easeInOut(duration: 0.2), - value: showsTypeLabel - ) - - VStack(alignment: .leading, spacing: 6.0) { - // Trailing gutter keeps the value clear of the copy / - // system-picker hover buttons; the value itself shrinks to fit - // two lines (see AdaptiveValueText). - AdaptiveValueText( - value: (eyedropper.color.usingColorSpace(colorSpace) ?? eyedropper.color) - .toFormat(format: colorFormat, style: copyFormat), - color: Color(eyedropper.color.getUIColor()) - ) - .padding(.trailing, 32.0) - - if !hideColorNames, adaptive.showsColorNames { - Text(eyedropper.getClosestColor()) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(eyedropper.color.getUIColor()) - } - } - } - .padding(.all, 10.0) - .modify { - let shadowColor: Color = eyedropper.color.getUIColor() == .white ? .black : .white - $0 - .shadow(color: shadowColor.opacity(0.30), radius: 0, x: 0, y: 1) - .shadow(color: shadowColor.opacity(0.10), radius: 3, x: 0, y: 0) + // Background pick target: a click anywhere that isn't the editable value (or the + // non-interactive labels above it, which fall through) starts a pick — unless a + // colour-value field is currently focused, in which case it just dismisses that + // field. See `PickTarget` above. + PickTarget( + onPick: { NSApp.sendAction(eyedropper.type.pickSelector, to: nil, from: nil) }, + onPressChange: { isPressed = $0 }, + onDismissEditing: { dismissEditingTrigger += 1 } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(eyedropper.color)) + .opacity(isPressed ? 0.8 : 1.0) + .animation(.easeIn(duration: 0.15), value: Color(eyedropper.color)) + .animation(.easeIn(duration: 0.15), value: isPressed) + + // Content overlay, lifted out of the pick button so the value's fields receive + // clicks. The type label and colour name disable hit-testing so clicks fall + // through to the pick button behind them. + VStack(alignment: .leading, spacing: 2.0) { + // `adaptive.showsTypeLabels` is hardcoded `true` (ContentView.swift) as of + // `81fe9f9`, so the fade below is currently vestigial — kept in case that's + // revisited, rather than stripped along with the conditional that once drove it. + // The invalid pill overrides the fade so it's never hidden. + let showsTypeLabel = adaptive.showsTypeLabels + HStack(alignment: .firstTextBaseline, spacing: 6.0) { + Text(eyedropper.type.description) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(eyedropper.color.getUIColor().opacity(0.75)) + if valueInvalid { + InvalidInputPill(uiColor: eyedropper.color.getUIColor()) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + // Reserves the pill's height in this row at all times (zero width, so it + // never otherwise affects layout) so toggling the pill doesn't change the + // row's height. The content below is anchored `.bottomLeading` in its parent + // frame, so any height change here shifts this label — the "Foreground" / + // "Background" text visibly jumping by a pixel each time invalid state was + // entered or exited. + InvalidInputPill(uiColor: .clear) + .fixedSize() + .frame(width: 0) + .accessibilityHidden(true) + } + .opacity(showsTypeLabel || valueInvalid ? 1 : 0) + .animation( + showsTypeLabel + ? .easeInOut(duration: 0.25).delay(0.3) + : .easeInOut(duration: 0.2), + value: showsTypeLabel + ) + .allowsHitTesting(false) + + // Spacing 0, with the gap above the colour name carried by that row's own + // height: the name has to be able to collapse to nothing, and a `VStack` + // spacing would still be contributing 6pt when it did. + VStack(alignment: .leading, spacing: 0) { + // Trailing gutter keeps the value clear of the copy / system-picker hover + // buttons; the value shrinks to fit as its column narrows. + EditableColorValue( + eyedropper: eyedropper, + format: colorFormat, + style: copyFormat, + colorSpace: colorSpace, + availableWidth: adaptive.swatchWidth, + isInvalid: $valueInvalid, + dismissEditingTrigger: dismissEditingTrigger + ) + .padding(.trailing, 32.0) + + // Always in the hierarchy, collapsing to zero height rather than being + // inserted and removed. This block is bottom-anchored, so a row appearing + // at full height shoves everything above it up by that much in a single + // frame — the jump you see when the colour name arrives. Animating the + // height (and fading in) turns that into a reveal instead. + Text(eyedropper.getClosestColor()) + .font(.system(size: colorNameFontSize, weight: .medium)) + .foregroundStyle(eyedropper.color.getUIColor()) + .allowsHitTesting(false) + .padding(.top, Self.colorNameSpacing) + .frame(height: colorNameVisible ? colorNameRowHeight : 0, alignment: .top) + .opacity(colorNameVisible ? 1 : 0) + .clipped() + } + .onAppear { colorNameVisible = wantsColorName } + .onChange(of: wantsColorName) { shows in + withAnimation(.easeInOut(duration: 0.2)) { colorNameVisible = shows } + } + } + .padding(.horizontal, 10.0) + .padding(.bottom, 10.0) + // Roomier above than below, so the hairline doesn't crowd the type label. + .padding(.top, 16.0) + // Measured *before* the shared height is imposed below, so this reports what the + // block naturally wants and can't feed back into its own answer. + .background( + GeometryReader { geo in + Color.clear.preference(key: ReadoutHeightKey.self, value: geo.size.height) } - }) - .buttonStyle(EyedropperButtonStyle(color: Color(eyedropper.color))) - .focusable(false) + ) + // Both swatches take the taller one's height, so the shield and the hairline that + // marks its edge line up across the pair even when one value wraps and the other + // doesn't. Bottom-aligned, so the extra height opens upward and the readout itself + // stays put. + .frame(height: readoutHeight > 0 ? readoutHeight : nil, alignment: .bottom) + .modify { + let shadowColor: Color = eyedropper.color.getUIColor() == .white ? .black : .white + $0 + .shadow(color: shadowColor.opacity(0.30), radius: 0, x: 0, y: 1) + .shadow(color: shadowColor.opacity(0.10), radius: 3, x: 0, y: 0) + } + // Both of these sit outside the shadow above, so the hairline stays crisp. + .background(ClickShield()) + // A single hairline along the top edge, marking where the block stops being a pick + // target — a full box around the text read as a control it isn't. + .overlay(alignment: .top) { + Rectangle() + .fill(Color(eyedropper.color.getUIColor()).opacity(showsReadoutBoundary ? 0.15 : 0)) + .frame(height: 1) + .allowsHitTesting(false) + } + .animation(.easeInOut(duration: 0.15), value: showsReadoutBoundary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) VStack(spacing: 4.0) { Button(action: { @@ -151,6 +291,19 @@ struct EyedropperButton: View { } .padding(.all, 8.0) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + + // Subtle affordance for a genuine screen pick landing on this swatch — most useful + // mid pick-pair, where it's otherwise a silent colour swap with nothing to tell you + // "that was the foreground" versus "that was the background". + Color.white + .opacity(flashOpacity) + .allowsHitTesting(false) + } + .onReceive(eyedropper.pickFlash) { + flashOpacity = 0.35 + withAnimation(.easeOut(duration: 0.35)) { + flashOpacity = 0 + } } .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in colorSpace = Defaults[.colorSpace] @@ -175,7 +328,8 @@ struct EyedropperButton: View { struct EyedropperButton_Previews: PreviewProvider { static var previews: some View { EyedropperButton( - eyedropper: Eyedropper(type: .foreground, color: PikaConstants.initialColors.randomElement()!) + eyedropper: Eyedropper(type: .foreground, color: PikaConstants.initialColors.randomElement()!), + dismissEditingTrigger: .constant(0) ) .frame(width: 170.0) } diff --git a/Pika/Views/EyedropperItem.swift b/Pika/Views/EyedropperItem.swift index cd081efb..947e913d 100644 --- a/Pika/Views/EyedropperItem.swift +++ b/Pika/Views/EyedropperItem.swift @@ -11,84 +11,97 @@ public extension NSPopUpButtonCell { struct EyedropperItem: View { @Environment(\.colorScheme) var colorScheme: ColorScheme @ObservedObject var eyedropper: Eyedropper + @Binding var dismissEditingTrigger: Int + /// Height to hold the readout block at, shared across both swatches by `ColorPickers`. + var readoutHeight: CGFloat = 0 + /// Whether the readout boundary hairline is showing — driven by hovering either swatch. + var showsReadoutBoundary: Bool = false @State private var showToast: Bool = false @Default(.colorFormat) var colorFormat @Default(.copyFormat) var copyFormat let pasteboard = NSPasteboard.general var body: some View { ZStack { - EyedropperButton(eyedropper: eyedropper) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.pickNotification)) { note in - let requestedChain = note.userInfo?["chain"] as? Bool == true - let chain = eyedropper.type == .foreground - && (Defaults[.pickContrastingColor] || requestedChain) - eyedropper.start(chainContrasting: chain) + EyedropperButton( + eyedropper: eyedropper, + dismissEditingTrigger: $dismissEditingTrigger, + readoutHeight: readoutHeight, + showsReadoutBoundary: showsReadoutBoundary + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.pickNotification)) { note in + let requestedChain = note.userInfo?["chain"] as? Bool == true + let chain = eyedropper.type == .foreground + && (Defaults[.pickContrastingColor] || requestedChain) + eyedropper.start(chainContrasting: chain) + } + .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.copyNotification)) { _ in + showToast = true + pasteboard.clearContents() + let contents = "\(eyedropper.color.toFormat(format: colorFormat, style: Defaults[.copyFormat]))" + pasteboard.setString(contents, forType: .string) + } + .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.systemPickerNotification)) { _ in + let panel = NSColorPanel.shared + if panel.isVisible, panel.title == "\(eyedropper.type.rawValue.capitalized)" { + panel.close() + } else { + eyedropper.picker() } - .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.copyNotification)) { _ in - showToast = true - pasteboard.clearContents() - let contents = "\(eyedropper.color.toFormat(format: colorFormat, style: Defaults[.copyFormat]))" - pasteboard.setString(contents, forType: .string) + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHex)) { _ in + if copyFormat != .swiftUI { + colorFormat = ColorFormat.hex } - .onReceive(NotificationCenter.default.publisher(for: eyedropper.type.systemPickerNotification)) { _ in - let panel = NSColorPanel.shared - if panel.isVisible, panel.title == "\(eyedropper.type.rawValue.capitalized)" { - panel.close() - } else { - eyedropper.picker() - } - } - .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHex)) { _ in - if copyFormat != .swiftUI { - colorFormat = ColorFormat.hex - } - } - .onReceive(NotificationCenter.default.publisher(for: .triggerFormatRGB)) { _ in - colorFormat = ColorFormat.rgb - } - .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSB)) { _ in - colorFormat = ColorFormat.hsb + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatRGB)) { _ in + colorFormat = ColorFormat.rgb + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSB)) { _ in + colorFormat = ColorFormat.hsb + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSL)) { _ in + if copyFormat != .swiftUI { + colorFormat = ColorFormat.hsl } - .onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSL)) { _ in - if copyFormat != .swiftUI { - colorFormat = ColorFormat.hsl - } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatOpenGL)) { _ in + if copyFormat != .swiftUI { + colorFormat = ColorFormat.opengl } - .onReceive(NotificationCenter.default.publisher(for: .triggerFormatOpenGL)) { _ in - if copyFormat != .swiftUI { - colorFormat = ColorFormat.opengl + } + .onChange(of: copyFormat) { + if copyFormat == .swiftUI { + if PikaConstants.disabledFormats.contains(colorFormat) { + colorFormat = .rgb } } - .onChange(of: copyFormat) { - if copyFormat == .swiftUI { - if PikaConstants.disabledFormats.contains(colorFormat) { - colorFormat = .rgb - } - } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatLAB)) { _ in + if copyFormat != .swiftUI { + colorFormat = .lab } - .onReceive(NotificationCenter.default.publisher(for: .triggerFormatLAB)) { _ in - if copyFormat != .swiftUI { - colorFormat = .lab - } - } - .onReceive(NotificationCenter.default.publisher(for: .triggerFormatOKLCH)) { _ in - if copyFormat != .swiftUI { - colorFormat = .oklch - } + } + .onReceive(NotificationCenter.default.publisher(for: .triggerFormatOKLCH)) { _ in + if copyFormat != .swiftUI { + colorFormat = .oklch } - .toast( - isShowing: $showToast, - color: eyedropper.color.getUIColor(), - text: Text(String(PikaText.textColorCopied)) - ) + } + .toast( + isShowing: $showToast, + color: eyedropper.color.getUIColor(), + text: Text(String(PikaText.textColorCopied)) + ) } } } struct EyedropperItem_Previews: PreviewProvider { static var previews: some View { - EyedropperItem(eyedropper: Eyedropper(type: .foreground, color: NSColor.black)) - .frame(width: 180.0) + EyedropperItem( + eyedropper: Eyedropper(type: .foreground, color: NSColor.black), + dismissEditingTrigger: .constant(0) + ) + .frame(width: 180.0) } } diff --git a/Pika/Views/FlowLayout.swift b/Pika/Views/FlowLayout.swift new file mode 100644 index 00000000..1887fe7b --- /dev/null +++ b/Pika/Views/FlowLayout.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Lays out children left-to-right, wrapping whole fragments (never mid-word, since each +/// fragment is measured and placed atomically) onto a new line once a fragment no longer fits — +/// so a squeezed row grows to two lines instead of clipping. +struct FlowLayout: Layout { + var lineSpacing: CGFloat = 0 + /// Backstop for a caller that's already sized its content to fit in this many lines (e.g. by + /// shrinking its font to target `maxLines * width`): once reached, remaining fragments pack + /// onto the last line instead of starting a new one, so a rounding/estimation miss overflows + /// horizontally rather than growing a line the caller didn't budget height for. `nil` (the + /// default) wraps to as many lines as needed. + var maxLines: Int? + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var lineWidth: CGFloat = 0, lineHeight: CGFloat = 0 + var totalWidth: CGFloat = 0, totalHeight: CGFloat = 0 + var line = 1 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if lineWidth > 0, lineWidth + size.width > maxWidth, maxLines.map({ line < $0 }) ?? true { + totalHeight += lineHeight + lineSpacing + totalWidth = max(totalWidth, lineWidth) + lineWidth = 0 + lineHeight = 0 + line += 1 + } + lineWidth += size.width + lineHeight = max(lineHeight, size.height) + } + totalHeight += lineHeight + totalWidth = max(totalWidth, lineWidth) + return CGSize(width: min(totalWidth, maxWidth), height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + var x = bounds.minX, y = bounds.minY, lineHeight: CGFloat = 0 + var line = 1 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.maxX, maxLines.map({ line < $0 }) ?? true { + x = bounds.minX + y += lineHeight + lineSpacing + lineHeight = 0 + line += 1 + } + subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size)) + x += size.width + lineHeight = max(lineHeight, size.height) + } + } +} diff --git a/Pika/Views/NavigationMenuItems.swift b/Pika/Views/NavigationMenuItems.swift index 42b1a9f2..f85dc150 100644 --- a/Pika/Views/NavigationMenuItems.swift +++ b/Pika/Views/NavigationMenuItems.swift @@ -130,6 +130,9 @@ struct NavigationMenuItems: View { Button(PikaText.textMenuAbout, action: { NSApp.sendAction(#selector(AppDelegate.openAboutWindow), to: nil, from: nil) }) + Button(PikaText.textMenuShowSplash, action: { + NSApp.sendAction(#selector(AppDelegate.openSplashWindow), to: nil, from: nil) + }) Button(PikaText.textMenuHelp, action: { NSApp.sendAction(#selector(AppDelegate.openHelpWindow), to: nil, from: nil) }) diff --git a/Pika/Views/PickerLoupeView.swift b/Pika/Views/PickerLoupeView.swift new file mode 100644 index 00000000..258345a9 --- /dev/null +++ b/Pika/Views/PickerLoupeView.swift @@ -0,0 +1,496 @@ +import AppKit +import Defaults +import SwiftUI + +/// Curved text laid out along an arc. Each glyph is advanced by its measured width, so +/// proportional (SF Pro) and monospaced fonts both space evenly. `centerAngle` is measured +/// clockwise from the top (0 = 12 o'clock); set `flip` on the bottom half so glyphs stay +/// upright and read left-to-right. +struct CircularText: View { + /// A run of text in one font; a label can mix fonts (e.g. mono value + sans name). + struct Segment { let text: String; let font: NSFont } + + let segments: [Segment] + var radius: CGFloat + var centerAngle: Double = 0 + var flip: Bool = false + + init(segments: [Segment], radius: CGFloat, centerAngle: Double = 0, flip: Bool = false) { + self.segments = segments + self.radius = radius + self.centerAngle = centerAngle + self.flip = flip + } + + init(text: String, radius: CGFloat, nsFont: NSFont, centerAngle: Double = 0, flip: Bool = false) { + self.init(segments: [Segment(text: text, font: nsFont)], + radius: radius, centerAngle: centerAngle, flip: flip) + } + + var body: some View { + let glyphs: [(char: String, font: NSFont)] = segments.flatMap { seg in + seg.text.map { (String($0), seg.font) } + } + let widths = glyphs.map { ($0.char as NSString).size(withAttributes: [.font: $0.font]).width } + let total = widths.reduce(0, +) + var running: CGFloat = 0 + var centers: [CGFloat] = [] + for width in widths { + centers.append(running + width / 2); running += width + } + + return ZStack { + ForEach(Array(glyphs.enumerated()), id: \.offset) { index, glyph in + let offset = centers[index] - total / 2 + let theta = centerAngle + (flip ? -1.0 : 1.0) * Double(offset / max(radius, 1)) + Text(glyph.char) + .font(Font(glyph.font)) + .rotationEffect(.radians(flip ? theta + .pi : theta)) + .offset(x: radius * sin(theta), y: -radius * cos(theta)) + } + } + // Pin to a symmetric frame centred on the arc origin. Without this the view's + // intrinsic bounds hug only the glyphs (a partial arc sits off-centre), so a parent + // ZStack re-centres that lopsided box and shifts the text off its band. + .frame(width: radius * 2, height: radius * 2) + } +} + +/// Shrink `base` until `text`, laid along a circle of `radius`, spans at most `maxArc` +/// radians — so a curved label never overruns its band. Never grows the font; floors at a +/// legible size. Preserves the font's family/weight via its descriptor. +private func fittedFont(_ text: String, base: NSFont, radius: CGFloat, maxArc: Double) -> NSFont { + let maxLength = CGFloat(maxArc) * radius + let width = (text as NSString).size(withAttributes: [.font: base]).width + guard width > maxLength, width > 0 else { return base } + let size = max(7.5, base.pointSize * (maxLength / width)) + return NSFont(descriptor: base.fontDescriptor, size: size) ?? base +} + +/// Like `fittedFont` but for a multi-font label: scales every part by the same factor so the +/// whole run fits `maxArc` while keeping the mono/sans mix. Below the 7.5pt floor, scaling alone +/// can't bound the run any further (e.g. a long colour name combined with a verbose format), so +/// any remaining overrun is ellipsized off the trailing segment rather than left to spill. +private func fittedSegments(_ parts: [(String, NSFont)], radius: CGFloat, maxArc: Double) -> [CircularText.Segment] { + let maxLength = CGFloat(maxArc) * radius + let total = parts.reduce(CGFloat.zero) { $0 + ($1.0 as NSString).size(withAttributes: [.font: $1.1]).width } + let scale = (total > maxLength && total > 0) ? maxLength / total : 1 + let segments = parts.map { text, font -> CircularText.Segment in + guard scale < 1 else { return CircularText.Segment(text: text, font: font) } + let size = max(7.5, font.pointSize * scale) + return CircularText.Segment(text: text, font: NSFont(descriptor: font.fontDescriptor, size: size) ?? font) + } + return truncateToFit(segments, maxLength: maxLength) +} + +/// Ellipsizes segments from the end until the run's total width fits `maxLength`, for when +/// scaling has already floored and the text still overruns its arc. +private func truncateToFit(_ segments: [CircularText.Segment], maxLength: CGFloat) -> [CircularText.Segment] { + func width(_ segs: [CircularText.Segment]) -> CGFloat { + segs.reduce(0) { $0 + ($1.text as NSString).size(withAttributes: [.font: $1.font]).width } + } + var result = segments + while width(result) > maxLength, let last = result.last { + var text = last.text + if text.hasSuffix("…") { text.removeLast() } + guard !text.isEmpty else { result.removeLast(); continue } + text.removeLast() + result[result.count - 1] = CircularText.Segment(text: text + "…", font: last.font) + } + return result +} + +/// The loupe: a circular window of magnified pixels (the sampled centre pixel outlined) with +/// the live readouts wrapped around it. Three themes (see `LoupeTheme`): +/// - `.lens`: the rim is filled with the hovered colour and engraved, SF Pro, with the format +/// around the top and the slot + colour name around the bottom. +/// - `.badge`: two white rounded badges (rotated 45°) hug the inside edge — format on one, +/// slot + colour name on the other. +/// - `.card`: a plain magnifier with no rim readouts; the format and slot + colour name sit in +/// a `LoupeReadoutCard` tucked beside the loupe circle instead. +/// +/// See `plans/ready/2026-07-19-custom-color-picker.md`. +struct LoupeCircle: View { + @ObservedObject var viewModel: LoupeViewModel + /// Overrides the user's theme preference (previews only). + var forcedTheme: LoupeTheme? + @Default(.colorFormat) private var colorFormat + @Default(.copyFormat) private var copyFormat + @Default(.loupeTheme) private var themePreference + @Environment(\.colorScheme) private var colorScheme + + private var theme: LoupeTheme { forcedTheme ?? themePreference } + + // Adapt to the system appearance: outline is black on light, white on dark; the badge is + // the inverse (white on light, black on dark) with matching text. + private var outlineColor: Color { colorScheme == .dark ? .white : .black } + + /// Fixed square side of the view (and its hosting panel), sized for the larger theme. + static let totalSize: CGFloat = 240 + + private let lensGlass: CGFloat = 150 + private let badgeGlass: CGFloat = 200 + /// Diameter of the plain magnifier used by the `.card` theme (the readout sits in a + /// separate panel beside it). Exposed so the controller can compute the card's clearance. + static let cardGlass: CGFloat = 140 + // Values are monospaced, colour names sans-serif (matching the card readout). + private let lensValueFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) + private let lensNameFont = NSFont.systemFont(ofSize: 12, weight: .medium) + private let badgeValueFont = NSFont.monospacedSystemFont(ofSize: 10, weight: .medium) + private let badgeNameFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + + var body: some View { + ZStack { + if viewModel.isOverApp { + // Over Pika's own windows the picker won't sample; show a distinct frosted + // "dismiss" disc instead of a faded picker so the intent is unambiguous. + dismissIndicator + } else { + switch theme { + case .lens: lens + case .badge: badge + case .card: card + } + } + } + .frame(width: Self.totalSize, height: Self.totalSize) + .shadow(color: .black.opacity(0.3), radius: 5, y: 2) + .animation(.easeInOut(duration: 0.2), value: theme) + .animation(.easeInOut(duration: 0.2), value: viewModel.comparison) + .animation(.easeInOut(duration: 0.2), value: viewModel.target) + .animation(.easeInOut(duration: 0.2), value: viewModel.isOverApp) + } + + /// Shown while the cursor is over one of Pika's own windows: a small liquid-glass disc with + /// a dismiss glyph. Clicking here commits the current colours (i.e. dismisses the pick). + private var dismissIndicator: some View { + let size: CGFloat = 74 + let mark = Image(systemName: "xmark") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.secondary) + return Group { + // `glassEffect` isn't declared in SDKs older than Xcode 26 (CI's pinned Xcode + // 16.3 among them) — `#available` alone doesn't help there, since the symbol + // is missing at compile time, not just unsupported at runtime. + #if compiler(>=6.2) + if #available(macOS 26.0, *) { + mark.frame(width: size, height: size) + .glassEffect(.clear.interactive(), in: .circle) + } else { + mark.frame(width: size, height: size) + .background(.ultraThinMaterial, in: Circle()) + .overlay(Circle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + } + #else + mark.frame(width: size, height: size) + .background(.ultraThinMaterial, in: Circle()) + .overlay(Circle().strokeBorder(.white.opacity(0.25), lineWidth: 1)) + #endif + } + } + + // MARK: - Card theme + + /// A plain magnifier disc on the cursor, in the style of the system sampler. The live + /// readouts ride in a separate `LoupeCardPanel` tucked beside it (managed by the controller). + private var card: some View { + glass(diameter: Self.cardGlass) + .overlay(Circle().strokeBorder(Color.white.opacity(0.9), lineWidth: 3) + .frame(width: Self.cardGlass, height: Self.cardGlass)) + .overlay(Circle().strokeBorder(Color.black.opacity(0.22), lineWidth: 1) + .frame(width: Self.cardGlass - 3, height: Self.cardGlass - 3)) + } + + // MARK: - Lens theme + + private var lens: some View { + let rimWidth: CGFloat = 28 + // Rim inner edge sits flush on the glass (no gap between magnifier and colour band). + let textRadius = lensGlass / 2 + rimWidth / 2 + let rimOuter = (textRadius + rimWidth / 2) * 2 + // Bottom half of the rim is the colour you're picking; the top half is the other + // colour of the pair, so you can compare them side by side. + let other = viewModel.comparison ?? viewModel.sampleColor + let anim = Animation.easeInOut(duration: 0.15) + let pairName = viewModel.comparison != nil ? viewModel.comparisonName : viewModel.colorName + let topSegments = fittedSegments( + lensParts(value: other.toFormat(format: colorFormat, style: copyFormat), name: pairName), + radius: textRadius, maxArc: 0.9 * .pi + ) + let bottomSegments = fittedSegments( + lensParts(value: formatText, name: viewModel.colorName), + radius: textRadius, maxArc: 0.9 * .pi + ) + return ZStack { + Circle().trim(from: 0, to: 0.5) + .stroke(Color(nsColor: viewModel.sampleColor), lineWidth: rimWidth) + .frame(width: textRadius * 2, height: textRadius * 2) + .animation(anim, value: viewModel.sampleColor) + Circle().trim(from: 0.5, to: 1.0) + .stroke(Color(nsColor: other), lineWidth: rimWidth) + .frame(width: textRadius * 2, height: textRadius * 2) + .animation(anim, value: other) + // Outer hairline defining the rim's outside edge (mirrors the badge's outer ring), + // so the rim reads as a crisp disc rather than fading into the backdrop. + Circle() + .strokeBorder(outlineColor.opacity(0.5), lineWidth: 1.5) + .frame(width: rimOuter, height: rimOuter) + glass(diameter: lensGlass) + Circle() + .strokeBorder(outlineColor.opacity(0.6), lineWidth: 2) + .frame(width: lensGlass, height: lensGlass) + // Each half carries its own colour's value + name: the pair on the top (other-colour) + // half, the picking colour on the bottom half — coloured for legibility on its own + // half, and shrunk to stay within its arc so long values (OKLCH) never spill over. + CircularText(segments: topSegments, radius: textRadius) + .foregroundStyle(adaptiveText(on: other)) + .animation(anim, value: other) + CircularText(segments: bottomSegments, radius: textRadius, centerAngle: .pi, flip: true) + .foregroundStyle(adaptiveText(on: viewModel.sampleColor)) + .animation(anim, value: viewModel.sampleColor) + } + } + + private func adaptiveText(on color: NSColor) -> Color { color.getUIColor() } + + // Each half shows its colour's value (monospaced) then name (sans), joined by " · ". + // Contrast now updates live in the main window's footer instead of on the rim. + private func lensParts(value: String, name: String) -> [(String, NSFont)] { + var parts: [(String, NSFont)] = [(value, lensValueFont)] + if !name.isEmpty { parts.append((" · \(name)", lensNameFont)) } + return parts + } + + // MARK: - Badge theme + + private let badgeLineWidth: CGFloat = 20 + private let bottomBadgeCenter = Double.pi + .pi / 4 // 7:30 + + private var badge: some View { + let badgeRadius = badgeGlass / 2 - 18 + // Both pills are the colour you're picking (value on top, name on the bottom); the pair + // is shown as the outer ring, so the whole badge frames what you're comparing against. + let pair = viewModel.comparison ?? viewModel.sampleColor + return ZStack { + glass(diameter: badgeGlass) + Circle() + .strokeBorder(Color(nsColor: pair), lineWidth: 3) + .frame(width: badgeGlass, height: badgeGlass) + .animation(.easeInOut(duration: 0.15), value: pair) + // Value pill in mono, name pill in sans — matching the card readout. + badgePill(badgeTopText, fill: viewModel.sampleColor, base: badgeValueFont, + radius: badgeRadius, centerAngle: .pi / 4, flip: false) + badgePill(badgeBottomText, fill: viewModel.sampleColor, base: badgeNameFont, + radius: badgeRadius, centerAngle: bottomBadgeCenter, flip: true) + } + } + + /// The fitted font, band fraction, and (possibly ellipsized) text for a badge label. Below + /// the font's 7.5pt floor, scaling alone can't bound the run any further, so any remaining + /// overrun is ellipsized — same fallback as the lens theme's `fittedSegments`. + private func badgeArc(_ text: String, base: NSFont, radius: CGFloat) -> (text: String, font: NSFont, fraction: Double) { + let pad: CGFloat = 18 + let padArc = Double(2 * pad / radius) + let maxArc = 0.44 * 2 * .pi - padArc + let font = fittedFont(text, base: base, radius: radius, maxArc: maxArc) + let maxLength = CGFloat(maxArc) * radius + let fitted = truncateToFit([CircularText.Segment(text: text, font: font)], maxLength: maxLength) + .first ?? CircularText.Segment(text: text, font: font) + let width = (fitted.text as NSString).size(withAttributes: [.font: fitted.font]).width + let fraction = min(0.44, (Double(width / radius) + padArc) / (2 * .pi)) + return (fitted.text, fitted.font, fraction) + } + + /// A rounded band filled with `fill` and engraved with curved text (auto-flipped for + /// legibility), centred at `centerAngle` (clockwise from the top). The font shrinks to keep + /// the text inside the band — the two pills are each capped to ~44% of the ring so they + /// never collide and glyphs never overrun the rounded caps. + private func badgePill(_ text: String, fill: NSColor, base: NSFont, radius: CGFloat, + centerAngle: Double, flip: Bool) -> some View + { + let lineWidth = badgeLineWidth + // The font shrinks to keep the text within ~44% of the ring (see `badgeArc`): that + // caps the arc so the two pills never collide AND — combined with drawing the band + // around the top (0.75) — keeps the trim range inside [0, 1]. + let (fittedText, font, fraction) = badgeArc(text, base: base, radius: radius) + // The band is a trimmed circle centred on the top (0.75). `Circle().trim` CLAMPS to + // [0, 1] rather than wrapping across the 3-o'clock seam, so a band whose range straddled + // the seam would be silently truncated (the cause of text spilling past the cap). Anchor + // it at the top where the range stays in-bounds, then rotate the finished band — and only + // the band — to the pill's position. The curved text places each glyph independently, so + // it needs no such trick. + let half = fraction / 2 + return ZStack { + ZStack { + // Hairline edge one step wider than the fill: keeps the pill crisp even when its + // colour matches the glass (the top pill over a solid-colour region would + // otherwise vanish into the same-coloured disc). + Circle() + .trim(from: 0.75 - half, to: 0.75 + half) + .stroke(outlineColor.opacity(0.5), style: StrokeStyle(lineWidth: lineWidth + 2, lineCap: .round)) + .shadow(color: .black.opacity(0.25), radius: 2.5, y: 1) + Circle() + .trim(from: 0.75 - half, to: 0.75 + half) + .stroke(Color(nsColor: fill), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + } + .frame(width: radius * 2, height: radius * 2) + .rotationEffect(.radians(centerAngle)) + CircularText(text: fittedText, radius: radius, nsFont: font, + centerAngle: centerAngle, flip: flip) + .foregroundStyle(adaptiveText(on: fill)) + } + // Grow/shrink the band and re-flow the glyphs smoothly as the readout changes, and + // crossfade the fill as the colour changes. + .animation(.easeInOut(duration: 0.15), value: text) + .animation(.easeInOut(duration: 0.15), value: fill) + } + + private var badgeTopText: String { formatText } + + // Just the colour name — contrast now updates live in the main window's footer. + private var badgeBottomText: String { viewModel.colorName } + + // MARK: - Glass + + private func glass(diameter: CGFloat) -> some View { + ZStack { + Color(nsColor: viewModel.sampleColor) + if let image = viewModel.image { + Image(decorative: image, scale: 1.0) + .resizable() + .interpolation(.none) + .antialiased(false) + } + centerCell(diameter: diameter) + } + .frame(width: diameter, height: diameter) + .clipShape(Circle()) + } + + /// One magnified pixel cell, outlined, marking the sampled centre pixel. + private func centerCell(diameter: CGFloat) -> some View { + let cell = diameter / CGFloat(max(1, viewModel.pixelCount)) + return Rectangle() + .strokeBorder(Color.white, lineWidth: 1) + .frame(width: cell, height: cell) + .overlay( + Rectangle() + .strokeBorder(Color.black, lineWidth: 1) + .padding(-1) + ) + } + + // MARK: - Readouts + + private var formatText: String { + viewModel.sampleColor.toFormat(format: colorFormat, style: copyFormat) + } +} + +/// The readout card tucked beside the loupe circle for the `.card` theme: the two colours of +/// the pair stacked, each full width with its value and name. Contrast now updates live in the +/// main window's footer rather than here. +struct LoupeReadoutCard: View { + @ObservedObject var viewModel: LoupeViewModel + @Default(.colorFormat) private var colorFormat + @Default(.copyFormat) private var copyFormat + + private let cardWidth: CGFloat = 240 + + // Colours stacked so each gets the full width — plenty of room for the value and name. + // Foreground always sits on top: `sampleColor` is whichever type is currently being + // picked, so during a chained pick's background leg that would otherwise put background + // above foreground, flipping the order the two colours read in between the two legs of + // the same pick. + var body: some View { + let samplePanel = panel(viewModel.sampleColor, name: viewModel.colorName) + let comparisonPanel = viewModel.comparison.map { panel($0, name: viewModel.comparisonName) } + VStack(spacing: 0) { + if viewModel.target == .foreground { + samplePanel + comparisonPanel + } else { + comparisonPanel + samplePanel + } + } + .frame(width: cardWidth) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 1) + ) + // Hidden over Pika's own windows — the disc shows the dismiss indicator instead. + .opacity(viewModel.isOverApp ? 0 : 1) + // Crossfade the panels and reflow between single/pair layouts as the readout changes. + .animation(.easeInOut(duration: 0.2), value: viewModel.sampleColor) + .animation(.easeInOut(duration: 0.2), value: viewModel.comparison) + .animation(.easeInOut(duration: 0.15), value: viewModel.isOverApp) + } + + /// One full-width colour panel: the colour fill, its value (monospaced) and name (sans), + /// in the legible contrast colour. + private func panel(_ color: NSColor, name: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(color.toFormat(format: colorFormat, style: copyFormat)) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.6) + if !name.isEmpty { + Text(name) + .font(.system(size: 11, weight: .medium)) + .opacity(0.8) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + .foregroundStyle(Color(nsColor: color.getUIColor())) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color(nsColor: color)) + } +} + +#if DEBUG + private func loupePreviewModel(sample: NSColor, comparison: NSColor?, name: String) -> LoupeViewModel { + let model = LoupeViewModel() + model.sampleColor = sample + model.comparison = comparison + model.colorName = name + model.pixelCount = 15 + return model + } + + #Preview("Lens") { + LoupeCircle( + viewModel: loupePreviewModel(sample: NSColor(hex: "e32c88"), comparison: .black, name: "Mystic Magenta"), + forcedTheme: .lens + ) + .padding(40) + .background(Color(white: 0.6)) + } + + #Preview("Badge") { + LoupeCircle( + viewModel: loupePreviewModel(sample: NSColor(hex: "e32c88"), comparison: .black, name: "Mystic Magenta"), + forcedTheme: .badge + ) + .padding(40) + .background(Color(white: 0.6)) + } + + #Preview("Card") { + HStack(spacing: 14) { + LoupeCircle( + viewModel: loupePreviewModel(sample: NSColor(hex: "e32c88"), comparison: .black, name: "Mystic Magenta"), + forcedTheme: .card + ) + LoupeReadoutCard( + viewModel: loupePreviewModel(sample: NSColor(hex: "e32c88"), comparison: .black, name: "Mystic Magenta") + ) + } + .padding(40) + .background(Color(white: 0.6)) + } +#endif diff --git a/Pika/Views/PreferencesView.swift b/Pika/Views/PreferencesView.swift index f72a8c67..7de01e85 100644 --- a/Pika/Views/PreferencesView.swift +++ b/Pika/Views/PreferencesView.swift @@ -19,6 +19,7 @@ private struct GeneralAndSelectionSection: View { @Default(.alwaysShowOnLaunch) var alwaysShowOnLaunch @Default(.showColorOverlay) var showColorOverlay @Default(.colorOverlayDuration) var colorOverlayDuration + @Default(.pickerStyle) var pickerStyle @State var disableHideMenuBarIcon = true var body: some View { @@ -88,17 +89,21 @@ private struct GeneralAndSelectionSection: View { .frame(maxWidth: .infinity, alignment: .leading) } .disabled(appMode == .menubarPopover) - Toggle(isOn: $showColorOverlay) { - Text(PikaText.textShowColorOverlay) - } - if showColorOverlay { - HStack(spacing: 8.0) { - Slider(value: $colorOverlayDuration, in: 1.0 ... 5.0, step: 0.5) - Text(String(format: "%.1fs", colorOverlayDuration)) - .font(.system(size: 11)) - .foregroundStyle(.secondary) + // The custom picker shows the colour live in the loupe, so the post-pick + // overlay (and its duration) is redundant and hidden while it's active. + if pickerStyle != .custom { + Toggle(isOn: $showColorOverlay) { + Text(PikaText.textShowColorOverlay) + } + if showColorOverlay { + HStack(spacing: 8.0) { + Slider(value: $colorOverlayDuration, in: 1.0 ... 5.0, step: 0.5) + Text(String(format: "%.1fs", colorOverlayDuration)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.leading, 20.0) } - .padding(.leading, 20.0) } } .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) @@ -121,6 +126,54 @@ private struct AppModeSection: View { } } +private struct PickerStyleSection: View { + @Default(.pickerStyle) var pickerStyle + @Default(.loupeTheme) var loupeTheme + @State private var pendingRelaunch = false + + var body: some View { + VStack(alignment: .leading, spacing: 10.0) { + Text(PikaText.textPickerStyleTitle).font(.system(size: 16)) + + // Same gated System/Custom comparison as the first-run splash. + PickerChoiceView(pendingRelaunch: $pendingRelaunch) + + // The Pro loupe's style. Only relevant when the custom picker is chosen. + if pickerStyle == .custom { + HStack(spacing: 8.0) { + Text(PikaText.textLoupeTheme).font(.system(size: 13)) + Spacer(minLength: 12.0) + Picker("", selection: $loupeTheme) { + ForEach(LoupeTheme.allCases, id: \.self) { theme in + Text(theme.localizedName).tag(theme) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + } + } + + // Pair picking is controlled by the single "Pick a contrasting + // background color after the foreground" toggle in the Selection + // section — it applies to both picker styles, so it isn't duplicated + // here per picker. + } + .padding(.horizontal, 24.0) + } +} + +private struct ColorNamesSection: View { + var body: some View { + // The picker owns its heading and description (with the refresh state on the right). + ColorListPickerView( + titleFont: .system(size: 16), + subtitleFont: .system(size: 12) + ) + .padding(.horizontal, 24.0) + } +} + private struct AppearanceSection: View { @Default(.contrastStandard) var contrastStandard @EnvironmentObject var eyedroppers: Eyedroppers @@ -329,6 +382,14 @@ struct PreferencesView: View { Divider().padding(.vertical, 16.0) + PickerStyleSection() + + Divider().padding(.vertical, 16.0) + + ColorNamesSection() + + Divider().padding(.vertical, 16.0) + AppearanceSection() Divider().padding(.vertical, 16.0) diff --git a/Pika/Views/ScrubTextField.swift b/Pika/Views/ScrubTextField.swift new file mode 100644 index 00000000..5cfc2ad4 --- /dev/null +++ b/Pika/Views/ScrubTextField.swift @@ -0,0 +1,544 @@ +import AppKit +import SwiftUI + +/// Captures two-finger trackpad scroll events landing within the wrapped view's bounds and +/// reports vertical delta/end, mirroring `HorizontalScrollWheelAdapter`'s local-monitor approach +/// so a scrub field can be nudged the same way click-drag nudges it. +struct ScrollValueAdapter: NSViewRepresentable { + let isEnabled: Bool + let onScroll: (CGFloat) -> Void + let onScrollEnd: () -> Void + + func makeNSView(context _: Context) -> ScrollCaptureView { + let view = ScrollCaptureView() + view.isEnabled = isEnabled + view.onScroll = onScroll + view.onScrollEnd = onScrollEnd + return view + } + + func updateNSView(_ view: ScrollCaptureView, context _: Context) { + view.isEnabled = isEnabled + view.onScroll = onScroll + view.onScrollEnd = onScrollEnd + } + + final class ScrollCaptureView: NSView { + var isEnabled = true + var onScroll: ((CGFloat) -> Void)? + var onScrollEnd: (() -> Void)? + private var monitor: Any? + private var isScrolling = false + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil, monitor == nil { + monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + self?.handle(event) ?? event + } + } else if window == nil, let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + } + + deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + } + + private func handle(_ event: NSEvent) -> NSEvent? { + guard isEnabled, event.window === window, let window else { return event } + + // Once we own an in-progress scroll, keep tracking it no matter where the cursor + // goes: trackpad momentum keeps delivering events (often with a final `.ended` + // phase) after the user's fingers leave the trackpad, and by then the cursor has + // frequently drifted off this field. Gating termination on `bounds.contains` meant + // that final event was silently dropped, `onScrollEnd` never fired, and the parent's + // edit session leaked open — so the *next* field touched would find a stale + // "already editing" session and force-focus itself. + if isScrolling { + if event.phase == .ended || event.phase == .cancelled || event.momentumPhase == .ended { + isScrolling = false + onScrollEnd?() + return event + } + guard event.hasPreciseScrollingDeltas else { return event } + let deltaY = event.scrollingDeltaY + guard deltaY != 0 else { return event } + onScroll?(deltaY) + return nil + } + + let pointInSelf = convert(event.locationInWindow, from: nil) + guard bounds.contains(pointInSelf) else { return event } + + // Only intervene for trackpad/Magic Mouse gesture scrolling, which carries + // phase/precise deltas; classic scroll wheels should keep their default behaviour. + guard event.hasPreciseScrollingDeltas else { return event } + guard event.phase != .ended, event.phase != .cancelled, event.momentumPhase != .ended else { return event } + + let deltaY = event.scrollingDeltaY + guard deltaY != 0 else { return event } + isScrolling = true + onScroll?(deltaY) + return nil + } + } +} + +/// Wraps `ScrubTextField` (below) for SwiftUI. Owns focus explicitly via `isFocused`/ +/// `onFocusChange` rather than `@FocusState`/`.focused()` (which don't bridge to a custom +/// `NSViewRepresentable`), synced through the field's own `NSTextFieldDelegate` callbacks so it +/// stays correct however focus changes — click, Tab, or a programmatic request. +struct ScrubbableColorField: NSViewRepresentable { + @Binding var text: String + let fontSize: CGFloat + let textColor: NSColor + let isDraggable: Bool + let range: ClosedRange? + let kind: ComponentKind + let isFocused: Bool + let onFocusChange: (Bool) -> Void + let onSubmit: () -> Void + let onCancel: () -> Void + /// Fired when a click-drag-to-scrub gesture starts/ends, so the parent can wrap it in the + /// same live-preview/commit session used for typed edits. + let onDragBegin: () -> Void + let onDragEnd: () -> Void + /// Escape pressed mid-drag — abandon the scrub and put the colour back how it was. + let onDragCancel: () -> Void + /// Fired with the raw live value on every drag step, so the parent can preview the eyedropper + /// colour without touching `text`, which stays frozen for the whole gesture. + let onLiveValue: (Double, Int) -> Double + /// Fired with +1/-1 for Up/Down arrow keys, `nil` for non-draggable (hex) fields. + let onStep: ((CGFloat) -> Void)? + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, onSubmit: onSubmit, onCancel: onCancel, onStep: onStep) + } + + func makeNSView(context: Context) -> ScrubTextField { + let field = ScrubTextField() + field.isBordered = false + field.drawsBackground = false + field.focusRingType = .none + // Left, not center: the field's frame is already sized to fit its text almost exactly + // (`intrinsicContentSize`, below), so centring only has ~2pt of slack to distribute. The + // static cell measures that slack with `NSString.size(withAttributes:)`; the live field + // editor lays the same string out via TextKit, which can measure it a device pixel + // narrower/wider — enough to visibly shift the centred text the moment editing begins. + // Left alignment anchors the text to the same edge under both renderers, so it doesn't move. + field.alignment = .left + field.usesSingleLineMode = true + field.cell?.wraps = false + field.delegate = context.coordinator + field.font = NSFont.systemFont(ofSize: fontSize, weight: .regular) + field.stringValue = text + return field + } + + func updateNSView(_ nsView: ScrubTextField, context: Context) { + context.coordinator.text = $text + // Driven off `becomeFirstResponder`/`resignFirstResponder` directly rather than the + // `NSTextFieldDelegate` controlTextDidBeginEditing/EndEditing notifications: a click that + // lands on a not-yet-focused field goes through AppKit's own private pre-focus path + // (`NSWindow._handleMouseDownEvent:` → `NSTextFieldCell _selectOrEdit:…`) *before* this + // view's `mouseDown` override ever runs, and that path never posts the notifications the + // delegate relies on — so `focusedIndex` silently never got set, and the focus outline + // never appeared. The responder overrides fire reliably however focus changes. + nsView.onFocusChange = onFocusChange + context.coordinator.onStep = onStep + if nsView.font?.pointSize != fontSize { + nsView.font = NSFont.systemFont(ofSize: fontSize, weight: .regular) + nsView.invalidateIntrinsicContentSize() + } + nsView.textColor = textColor + nsView.isDraggable = isDraggable + nsView.range = range + nsView.kind = kind + nsView.onDragBegin = onDragBegin + nsView.onDragCancel = { [weak nsView] in + nsView?.lastAchievedValue = nil + onDragCancel() + } + // The field's own `stringValue` is deliberately never touched here — it stays frozen at + // whatever it showed when the drag began, for `FlowLayout`'s benefit (see + // `EditableColorValue.rowScrubPreview`). Only the floating pill sees the live value. + nsView.onDragChanged = { [weak nsView] newValue in + guard let nsView else { return } + // The achieved value, not the requested one: a drag past the sRGB gamut boundary + // clamps, and the pill must show what the swatch actually is. + let achieved = onLiveValue(newValue, nsView.dragDecimalPlaces) + nsView.lastAchievedValue = achieved + } + nsView.onDragEnd = { [weak nsView] finalValue in + guard let nsView else { return } + text = ColorComponentField.formattedDragValue( + nsView.lastAchievedValue ?? finalValue, kind: kind, stableDecimalPlaces: nsView.dragDecimalPlaces + ) + nsView.lastAchievedValue = nil + onDragEnd() + } + + if nsView.stringValue != text { + ScrubTextField.setText(text, on: nsView) + } + + let editorIsActive = nsView.currentEditor() != nil && nsView.window?.firstResponder === nsView.currentEditor() + if isFocused, !editorIsActive { + nsView.window?.makeFirstResponder(nsView) + } else if !isFocused, editorIsActive { + nsView.window?.makeFirstResponder(nil) + } + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + var text: Binding + let onSubmit: () -> Void + let onCancel: () -> Void + var onStep: ((CGFloat) -> Void)? + + init( + text: Binding, onSubmit: @escaping () -> Void, onCancel: @escaping () -> Void, + onStep: ((CGFloat) -> Void)? + ) { + self.text = text + self.onSubmit = onSubmit + self.onCancel = onCancel + self.onStep = onStep + } + + func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? ScrubTextField else { return } + // Clearing a ranged field (select-all + delete, or backspacing the last digit) + // leaves it both empty and invalid — effectively a dead end, since an empty + // `.fixedSize()` field also collapses to no width, hiding the invalid-state dashes + // that would otherwise show. Snap it to the component's lowest value instead, and + // select it, so the field stays visible, valid, and ready to be typed straight over + // — mirroring what a fresh click-to-select does. Hex has no natural minimum + // (`range` is nil), so it keeps the plain empty/invalid state. + if field.stringValue.isEmpty, let range = field.range { + let lowest = ColorComponentField.formattedDragValue(range.lowerBound, kind: field.kind) + ScrubTextField.setText(lowest, on: field, selectAll: true) + text.wrappedValue = lowest + return + } + text.wrappedValue = field.stringValue + } + + func control(_: NSControl, textView _: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + onSubmit() + return true + } + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + onCancel() + return true + } + if commandSelector == #selector(NSResponder.moveUp(_:)), let onStep { + onStep(1) + return true + } + if commandSelector == #selector(NSResponder.moveDown(_:)), let onStep { + onStep(-1) + return true + } + return false + } + } +} + +/// A plain `NSTextField` subclass that owns its own `mouseDown`, so click-vs-drag is resolved +/// *before* anything can focus — no second view or event monitor racing the field's native click +/// handling. A resolved drag never touches first-responder status at all, so the field just +/// displays a changing string like a label while scrubbing (no editor, no selection, no caret). +/// A resolved click focuses normally; `becomeFirstResponder` then deterministically selects the +/// whole value in the same call stack (matching the click-to-select-all behaviour of a spreadsheet +/// cell), rather than reacting to AppKit's own click-positions-the-caret behaviour after the fact. +final class ScrubTextField: NSTextField { + var isDraggable = false { + didSet { window?.invalidateCursorRects(for: self) } + } + + var range: ClosedRange? + var kind: ComponentKind = .integer + var onDragBegin: (() -> Void)? + var onDragChanged: ((Double) -> Void)? + /// Fires with the drag's final value once it ends. + var onDragEnd: ((Double) -> Void)? + /// Fires instead of `onDragEnd` when the drag is abandoned with Escape. + var onDragCancel: (() -> Void)? + /// Reports true/false as this field becomes/resigns first responder. Driven from these + /// overrides rather than `NSTextFieldDelegate`'s controlTextDidBeginEditing/EndEditing — + /// see the note at the `onFocusChange` assignment in `ScrubbableColorField.updateNSView`. + var onFocusChange: ((Bool) -> Void)? + + /// The most recent value `onDragChanged` reported — always set by the time `finishDrag` can + /// run, since `updateDrag` fires at least once (immediately after `beginDrag`) before a + /// `mouseUp` can be reached. Read once, then cleared, to hand `onDragEnd` its final value. + private var lastDragValue: Double? + /// Raised only while the click branch is deliberately asking for focus, so + /// `becomeFirstResponder`'s mouse-down refusal stands aside for that one request. + private var isResolvingPress = false + /// Value/x-position the current drag measures its horizontal offset from. Re-anchored + /// whenever vertical movement changes precision, so rescaling the axis mid-drag doesn't make + /// the value jump — it just changes how far a pixel moves it from wherever it already is. + private var dragAnchorValue: Double? + private var dragAnchorX: CGFloat = 0 + /// Decimal places the drag started at; vertical movement offsets from this, and the + /// per-pixel step scales inversely so the last shown digit always advances about one per + /// pixel (otherwise a coarse readout looks frozen while the colour visibly changes). + private var dragBaseDecimalPlaces = 2 + /// The gamut-clamped value the last drag step actually achieved (see + /// `EditableColorValue.previewLiveScrub`), so the commit uses reality rather than the raw + /// requested value. Cleared once the drag ends. + var lastAchievedValue: Double? + /// Decimal places to hold this drag's live display at — captured once at drag start (see + /// `ColorComponentField.stableDecimalPlaces(for:)`) and held fixed for the drag, rather than + /// recomputed every pixel of movement, so the value's own live-updating string never itself + /// becomes the thing shifting the row's wrap point mid-drag. + var dragDecimalPlaces = 2 + + /// Replace `field`'s displayed text with `newValue`. While `field`'s own field editor is the + /// active first responder, this routes through the editor's `NSTextInputClient.insertText` + /// rather than assigning `stringValue` directly — that's what lets a *programmatic* change + /// mid-session (an arrow-key step, the empty-field snap-to-lowest below, a drag/scroll + /// landing on it) register with the window's `undoManager` the same way a keystroke does. + /// A direct `stringValue` write bypasses that machinery entirely: it doesn't just fail to + /// register its own undo step, it silently blows away whatever undo grouping the user's own + /// prior typing had already built up, breaking Cmd-Z for the rest of the session. Falls back + /// to a plain `stringValue` write when there's no active editor to preserve. + static func setText(_ newValue: String, on field: ScrubTextField, selectAll: Bool = false) { + guard field.stringValue != newValue else { return } + let length = (newValue as NSString).length + if let editor = field.currentEditor() as? NSTextView, field.window?.firstResponder === editor { + let full = NSRange(location: 0, length: (editor.string as NSString).length) + editor.insertText(newValue, replacementRange: full) + editor.selectedRange = selectAll ? NSRange(location: 0, length: length) : NSRange(location: length, length: 0) + } else { + field.stringValue = newValue + field.invalidateIntrinsicContentSize() + } + } + + // The default NSTextFieldCell intrinsic size proved unreliable once `.fixedSize()` queried + // it eagerly (fields collapsed to ~0pt wide) — compute it directly from the string and font + // instead of trusting the cell's own layout pass. + override var intrinsicContentSize: NSSize { + let currentFont = font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize) + let size = (stringValue as NSString).size(withAttributes: [.font: currentFont]) + return NSSize(width: ceil(size.width) + 2, height: ceil(size.height)) + } + + override func becomeFirstResponder() -> Bool { + // AppKit's own `_setUpFirstResponder`/`_selectFirstKeyView` auto-focuses the first key + // view in the window while it's still being ordered onto screen — before it's key — + // which would open an edit session (and select-all) on the hue field before the user + // has clicked anything. Reject that call outright; genuine focus (click, Tab, or our + // own `updateNSView` reconciliation) only ever happens once the window is already key. + guard window?.isKeyWindow == true else { return false } + // Refuse focus that a mouse-down on this field is driving. AppKit focuses (and + // select-all's) the clicked field *before* dispatching `mouseDown`, so there is no + // earlier hook to raise a flag from — by the time our own `mouseDown` runs the selection + // has already been drawn, and resigning after the fact is what makes it flash. Reading + // the event currently being dispatched is the only way to catch it in time. The press + // may still turn out to be a scrub, where focusing at all is wrong; if it turns out to + // be a click, the `.leftMouseUp` branch asks again with `isResolvingPress` set and this + // check stands aside. + if isDraggable, !isResolvingPress, let event = NSApp.currentEvent, event.type == .leftMouseDown, + bounds.contains(convert(event.locationInWindow, from: nil)) + { + return false + } + let result = super.becomeFirstResponder() + if result { + if let editor = currentEditor() { + let length = (editor.string as NSString).length + editor.selectedRange = NSRange(location: 0, length: length) + // The shared field editor's default `textContainerInset` doesn't match the metrics + // the static `NSTextFieldCell` used to draw the same string, so the text visibly + // jumps by a device pixel the instant editing begins. Zero it so the editor draws + // the text in exactly the same place the cell did. + if let textView = editor as? NSTextView { + textView.textContainerInset = .zero + } + } + window?.invalidateCursorRects(for: self) + onFocusChange?(true) + } + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if result { + window?.invalidateCursorRects(for: self) + onFocusChange?(false) + } + return result + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.invalidateCursorRects(for: self) + // Without this, AppKit's own `_setUpFirstResponder`/`_selectFirstKeyView` auto-focuses + // the first key view in the window (i.e. this field, if it's first in the hierarchy) the + // moment the window becomes key — opening an edit session on the hue field before the + // user has clicked anything. `becomeFirstResponder` reports that focus like any other + // (correctly, so genuine focus changes stay in sync), so SwiftUI accepts it and shows the + // outline. Steer AppKit's auto-pick to the content view instead, which never becomes an + // editing session. Harmless to set repeatedly (once per field that attaches). + if let window, window.initialFirstResponder !== window.contentView { + window.initialFirstResponder = window.contentView + } + } + + // NSTextField's own `resetCursorRects()` covers `bounds` with an I-beam cursor rect, which + // wins over the SwiftUI `.onHover`-driven `NSCursor.set()` the moment the mouse enters this + // AppKit view — that's why the resize cursor from the parent's hover handling was reverting + // to `|` over the text itself. Claim the rect ourselves while draggable and not being edited. + override func resetCursorRects() { + guard isDraggable, currentEditor() == nil else { + super.resetCursorRects() + return + } + addCursorRect(bounds, cursor: .resizeLeftRight) + } + + override func mouseDown(with event: NSEvent) { + guard isDraggable else { + super.mouseDown(with: event) + return + } + + let startPoint = event.locationInWindow + var didBeginDrag = false + let threshold: CGFloat = 2 + + // Every exit path below must resolve exactly one of "focus" or a paired + // onDragBegin/onDragEnd — an orphaned "began but never ended" session would leave the + // parent's edit session stuck open, making the next interaction silently join it instead + // of starting fresh. + while true { + guard let next = NSApp.nextEvent( + matching: [.leftMouseDragged, .leftMouseUp, .keyDown], + until: .distantFuture, + inMode: .eventTracking, + dequeue: true + ) else { + if didBeginDrag { finishDrag() } + return + } + + switch next.type { + case .keyDown: + // Escape abandons the scrub. Only meaningful once a drag is actually under way; + // otherwise the key is dropped rather than dispatched (narrow window: only + // while the mouse button is held on this field). + guard didBeginDrag, next.keyCode == 53 else { continue } + cancelDrag() + return + case .leftMouseDragged: + let translationX = next.locationInWindow.x - startPoint.x + if !didBeginDrag { + guard abs(translationX) >= threshold else { continue } + didBeginDrag = true + beginDrag(at: next.locationInWindow) + } + updateDrag(location: next.locationInWindow, startPoint: startPoint) + case .leftMouseUp: + if didBeginDrag { + finishDrag() + } else { + // A genuine click: focus normally, exactly like a plain click on any + // ordinary text field would. AppKit's own event routing + // (`_handleMouseDownEvent:` → `NSTextFieldCell _selectOrEdit:`) already + // focuses the field before this override even runs — calling + // `makeFirstResponder` again here forces a redundant resign/become pair + // that corrupts the field editor's begin-editing bookkeeping, so + // `controlTextDidBeginEditing` silently never fires and the field never + // reports itself focused to SwiftUI (no outline, no edit session). Once + // focused, first responder is the *field editor* (an NSTextView), not this + // control itself, so check against `currentEditor()` rather than `self`. + isResolvingPress = true + defer { isResolvingPress = false } + let editorIsActive = currentEditor() != nil && window?.firstResponder === currentEditor() + if !editorIsActive { + window?.makeFirstResponder(self) + } + } + return + default: + if didBeginDrag { finishDrag() } + return + } + } + } + + private func beginDrag(at location: NSPoint) { + dragAnchorValue = Double(stringValue.trimmingCharacters(in: .whitespaces)) ?? 0 + dragAnchorX = location.x + dragDecimalPlaces = ColorComponentField.initialScrubDecimalPlaces(forRange: range, text: stringValue) + dragBaseDecimalPlaces = dragDecimalPlaces + NSCursor.resizeLeftRight.set() + onDragBegin?() + } + + /// Points of vertical travel per decimal place gained or lost. + private static let pointsPerPrecisionStep: CGFloat = 40 + /// Bounds on scrub precision. Never 0: a 0...1 component (OKLCH chroma) would read a constant + /// "0" and look broken. 4 matches the widest the normal stripped display ever shows. Not + /// private: `EditableColorValue`'s scroll-to-scrub path clamps to the same ceiling. + static let precisionRange = 1 ... 4 + + private func updateDrag(location: NSPoint, startPoint: NSPoint) { + guard dragAnchorValue != nil else { return } + + // Vertical travel picks the precision — dragging down (which decreases y in AppKit's + // bottom-left window coordinates) reveals more decimals, up rounds them off. Only for + // `.decimal`; integers have no decimals to trade. + if kind == .decimal { + let steps = Int(((startPoint.y - location.y) / Self.pointsPerPrecisionStep).rounded()) + let wanted = min(max(dragBaseDecimalPlaces + steps, Self.precisionRange.lowerBound), + Self.precisionRange.upperBound) + if wanted != dragDecimalPlaces { + // Re-anchor before rescaling, so only the sensitivity changes, not the value. + dragAnchorValue = lastDragValue ?? dragAnchorValue + dragAnchorX = location.x + dragDecimalPlaces = wanted + } + } + + guard let anchorValue = dragAnchorValue else { return } + let fine = NSEvent.modifierFlags.contains(.option) + // Scale the per-pixel step against the precision on show, so one pixel moves roughly one + // unit of the last visible digit at every precision. + // Only ever *finer* than the drag started: going coarser keeps the original step, so + // rounding the readout off doesn't also make the drag 10x faster and slam the value into + // its range bound (chroma pinned at 1.0 renders as magenta, nowhere near the hue shown). + let scale = pow(10.0, Double(min(0, dragBaseDecimalPlaces - dragDecimalPlaces))) + let unitsPerStep = dragUnitsPerPixel(for: range) * scale + var newValue = anchorValue + Double(location.x - dragAnchorX) * unitsPerStep * (fine ? 0.1 : 1.0) + if let range { + newValue = min(max(newValue, range.lowerBound), range.upperBound) + } + lastDragValue = newValue + onDragChanged?(newValue) + } + + private func cancelDrag() { + dragAnchorValue = nil + lastDragValue = nil + NSCursor.arrow.set() + onDragCancel?() + } + + private func finishDrag() { + dragAnchorValue = nil + NSCursor.arrow.set() + if let lastDragValue { + onDragEnd?(lastDragValue) + } + lastDragValue = nil + } +} diff --git a/Pika/Views/SplashView.swift b/Pika/Views/SplashView.swift index 2179f387..948ba45e 100644 --- a/Pika/Views/SplashView.swift +++ b/Pika/Views/SplashView.swift @@ -1,41 +1,612 @@ +import ApplicationServices +import Defaults import KeyboardShortcuts import LaunchAtLogin import SwiftUI struct SplashView: View { + @Default(.pickerStyle) var pickerStyle + @State private var hostWindow: NSWindow? + @State private var pendingRelaunch = false + // Pre-selected: the common case is to see the splash once and not again. Unchecking it + // keeps the splash appearing on launch. Persisted to `hideSplashOnLaunch` on dismissal. + @State private var dontShowAgain = true + + // The custom picker is only the active choice once permission exists. "Get started" is + // the primary action only then — otherwise Grant Permission is the primary call. + private var customActive: Bool { CustomColorPickSession.isAvailable && pickerStyle == .custom } + var body: some View { - VStack(spacing: 0) { + HStack(spacing: 0) { + // Left: branded visualisation (shader + Liquid Glass eye). ZStack { Color(red: 0.4, green: 0.0, blue: 0.7) Visualisation() SplashEye() - .frame(maxWidth: 210.0) + .frame(maxWidth: 150.0) .offset(x: 0.0, y: 5.0) - .padding(.vertical, 48.0) } + .frame(width: 260.0) .frame(maxHeight: .infinity) - Divider() - HStack(spacing: 16.0) { - HStack { - Text(PikaText.textSplashLaunch) - KeyboardShortcuts.Recorder(for: .togglePika) + + // Right: explained setup list with a pinned footer. + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16.0) { + VStack(alignment: .leading, spacing: 10.0) { + Image("AboutIcon") + .resizable() + .scaledToFit() + .frame(width: 52.0, height: 52.0) + VStack(alignment: .leading, spacing: 4.0) { + Text(PikaText.textSplashSetupTitle) + .font(.system(size: 20, weight: .bold)) + Text(PikaText.textSplashSetupSubtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + + SplashSettingRow( + title: PikaText.textSplashLaunch, + subtitle: PikaText.textSplashShortcutSubtitle + ) { + KeyboardShortcuts.Recorder(for: .togglePika) + } + + SplashSettingRow( + title: PikaText.textPickPair, + subtitle: PikaText.textSplashPairSubtitle + ) { + KeyboardShortcuts.Recorder(for: .pickPair) + } + + SplashSettingRow( + title: PikaText.textSplashHotkey, + subtitle: PikaText.textSplashLaunchSubtitle + ) { + LaunchAtLogin.Toggle {} + .labelsHidden() + .toggleStyle(.switch) + } + + VStack(alignment: .leading, spacing: 8.0) { + Text(PikaText.textPickerStyleTitle) + .font(.system(size: 13, weight: .semibold)) + PickerChoiceView(pendingRelaunch: $pendingRelaunch) + } + + // Label above a full-width dropdown (the component owns its heading), + // matching the picker block above and the Settings layout. + ColorListPickerView() + } + // Fill the column so children lay out against a definite width (a + // ScrollView otherwise proposes an ambiguous width, which let flexible + // rows collapse to their minimum on re-layout). + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20.0) + .padding(.top, 52.0) + .padding(.bottom, 20.0) } + // Anchor to the bottom: when the list is taller than the window it starts + // scrolled to the end (the last settings + footer stay in view) rather than + // hiding them below the fold. + .defaultScrollAnchor(.bottom) Divider() - LaunchAtLogin.Toggle { - Text(PikaText.textSplashHotkey) + HStack(spacing: 12.0) { + Toggle(isOn: $dontShowAgain) { + Text(PikaText.textSplashDontShowAgain) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .toggleStyle(.checkbox) + Spacer(minLength: 12.0) + if customActive { + Button(action: handleGetStarted, label: { Text(PikaText.textSplashStart) }) + .keyboardShortcut(.defaultAction) + .tint(Color.accentColor) + } else { + Button(action: handleGetStarted, label: { Text(PikaText.textSplashStart) }) + .buttonStyle(.bordered) + } } + .padding(.horizontal, 24.0) + .padding(.vertical, 12.0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + } + .background(SplashWindowAccessor(window: $hostWindow)) + } - Divider() + private func closeSplash() { + // Persist the pre-selected checkbox: leave it ticked and the splash won't return. + Defaults[.hideSplashOnLaunch] = dontShowAgain + NSApp.sendAction(#selector(AppDelegate.closeSplashWindow), to: nil, from: nil) + } + + // If the user is leaving on the system picker, nudge them toward the custom one once + // before closing. Choosing "Enable" runs the permission flow and keeps the splash open + // so they see the result; otherwise we proceed with the system picker. + private func handleGetStarted() { + guard pickerStyle != .custom else { closeSplash(); return } + + let alert = NSAlert() + alert.messageText = PikaText.textSplashConfirmTitle + alert.informativeText = PikaText.textSplashConfirmBody + alert.alertStyle = .informational + alert.addButton(withTitle: PikaText.textSplashConfirmEnable) + alert.addButton(withTitle: PikaText.textSplashConfirmContinue) + + let handle: (NSApplication.ModalResponse) -> Void = { response in + if response == .alertFirstButtonReturn { + PickerChoiceView.requestAccess(pendingRelaunch: $pendingRelaunch) + } else { + closeSplash() + } + } + + if let hostWindow { + alert.beginSheetModal(for: hostWindow, completionHandler: handle) + } else { + handle(alert.runModal()) + } + } +} + +/// One explained setting row: title + subtitle on the left, a control on the right. +private struct SplashSettingRow: View { + let title: String + let subtitle: String + @ViewBuilder let control: Control + + var body: some View { + HStack(alignment: .center, spacing: 12.0) { + VStack(alignment: .leading, spacing: 2.0) { + Text(title).font(.system(size: 13, weight: .semibold)) + Text(subtitle).font(.system(size: 11)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 12.0) + control + } + } +} + +/// The colour-name list chooser, shared by the splash and Settings. Shows the active list +/// (Default by default) and lets the user pick any list published by color.pizza. Offline, +/// only Default (and any list already cached) is guaranteed; the full catalogue loads from +/// the network when reachable. Selecting a list is handled by `ColorNamesManager`, which +/// also falls the choice back to Default if the API stops offering it. +struct ColorListPickerView: View { + // Callers set the heading sizes (splash uses smaller type than Settings). + var titleFont: Font = .system(size: 13, weight: .semibold) + var subtitleFont: Font = .system(size: 11) + + // Height of the custom dropdown pill. + private let pickerHeight: CGFloat = 24.0 + + @ObservedObject private var manager = ColorNamesManager.shared + @Default(.colorNameList) private var colorNameList + + private var options: [ColorListInfo] { + var infos = manager.availableLists + // Before the catalogue loads, still offer Default so the control is usable offline. + if infos.isEmpty { + infos = [ColorListInfo(key: defaultColorListKey, title: PikaText.textColorListDefault)] + } + // Keep a stored non-default selection renderable even if it's not (yet) in the list. + if !infos.contains(where: { $0.key == colorNameList }) { + infos.append(ColorListInfo(key: colorNameList, title: colorNameList)) + } + return infos + } + + private var selectedTitle: String { + options.first { $0.key == colorNameList }?.title ?? colorNameList + } + + var body: some View { + VStack(alignment: .leading, spacing: 8.0) { + // Heading + description, with the refresh spinner pinned to the top-right of the + // text — keeping it out of the dropdown's row so it can't change its width. + HStack(alignment: .top, spacing: 12.0) { + VStack(alignment: .leading, spacing: 2.0) { + Text(PikaText.textColorListTitle).font(titleFont) + Text(PikaText.textColorListSubtitle) + .font(subtitleFont) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + // Claim the row's width so the text can't collapse to its minimum (one char + // per line) during a re-layout — a greedy `Spacer` here would let it. The + // spinner then sits at the trailing edge. + .frame(maxWidth: .infinity, alignment: .leading) + // Show a spinner while the catalogue / colours refresh so a fetch isn't silent. + if manager.isFetching { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + } + + // A custom pill instead of a native `Picker`: the macOS `.menu` pop-up bezel + // hugs its content and won't span the container edge-to-edge no matter how its + // frame is sized, so it always read as inset. This `Menu` with a full-width + // rounded label fills the column and matches the picker-comparison block above. + Menu { + Picker("", selection: Binding( + get: { colorNameList }, + set: { ColorNamesManager.shared.selectList($0) } + )) { + ForEach(options) { info in + Text(info.title).tag(info.key) + } + } + .labelsHidden() + .pickerStyle(.inline) + } label: { + HStack(spacing: 6.0) { + Text(selectedTitle) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8.0) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10.0, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10.0) + .frame(height: pickerHeight) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 6.0, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6.0, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 1.0) + ) + .contentShape(Rectangle()) + } + // `.plain` renders the label exactly as given (full-width pill) instead of the + // default bordered menu button, which hugged its content and ignored the fill. + .buttonStyle(.plain) + .menuIndicator(.hidden) + } + // Keep the whole block full width so its children lay out against a definite width. + .frame(maxWidth: .infinity, alignment: .leading) + // Surface the last-updated time, an in-progress check, or an offline error on hover. + .help(manager.statusDescription) + .onAppear { manager.loadAvailableListsIfNeeded() } + } +} + +/// The System vs Custom picker comparison, shared by the splash and Settings. Two selectable +/// tiles — System (selected by default) and Custom — with Custom disabled until Screen +/// Recording permission exists, plus a primary "Grant Permission" action and, once requested, +/// relaunch guidance (a first-time grant only takes effect after relaunch). +struct PickerChoiceView: View { + @Binding var pendingRelaunch: Bool + @Default(.pickerStyle) private var pickerStyle + // Backing state for the two permission checks. `CustomColorPickSession.isAvailable` and + // `AXIsProcessTrusted()` read non-observable system APIs, so reading them establishes no + // SwiftUI dependency on its own. Mirror them into @State, refreshed by the poll and the + // system notifications below, so the pills re-render when a permission actually flips — + // without forcing this subtree's identity to change every tick (an `.id()` on it would tear + // down and rebuild the Grant buttons once a second, risking a swallowed click if a press + // landed mid-reset). + @State private var hasScreenRecording = CustomColorPickSession.isAvailable + @State private var hasAccessibilityAccess = AXIsProcessTrusted() + + private var hasPermission: Bool { hasScreenRecording } + // Optional: unlocks global Escape / arrow-nudge while picking over other apps. + private var hasAccessibility: Bool { hasAccessibilityAccess } + // Custom is only truly active once permission exists; until then System stays selected + // and the Custom tile is disabled. + private var customActive: Bool { hasPermission && pickerStyle == .custom } + + /// Re-read the (non-observable) system permission state into @State. Equal writes are + /// coalesced by SwiftUI, so this only drives a re-render when a permission actually changes. + private func refreshPermissionState() { + hasScreenRecording = CustomColorPickSession.isAvailable + hasAccessibilityAccess = AXIsProcessTrusted() + } + + var body: some View { + VStack(spacing: 10.0) { + HStack(alignment: .top, spacing: 16.0) { + PickerComparisonTile( + style: .system, + title: PikaText.textPickerSystemTitle, + description: PikaText.textPickerSystemDescription, + selected: !customActive, + disabled: false, + onSelect: { pickerStyle = .system; pendingRelaunch = false } + ) + PickerComparisonTile( + style: .custom, + title: PikaText.textPickerCustomTitle, + description: PikaText.textPickerCustomDescription, + selected: customActive, + disabled: !hasPermission, + badge: PikaText.textSplashPickerRecommended, + footnote: (icon: "record.circle", text: PikaText.textPickerRequiresScreenRecording), + onSelect: { pickerStyle = .custom } + ) + } + + // The pills read `hasScreenRecording`/`hasAccessibilityAccess` (@State), so they + // re-render on their own when `refreshPermissionState` flips one — no `.id()` identity + // churn needed to force it. + permissionArea + } + .padding(12.0) + .background( + RoundedRectangle(cornerRadius: 10.0, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10.0, style: .continuous) + .strokeBorder(Color.primary.opacity(0.1), lineWidth: 1.0) + .allowsHitTesting(false) + ) + // Poll on a loop that survives re-renders (an inline Timer.publish would be reset by + // the colour-name fetcher's frequent republishing before it ever fires). This re-reads + // the non-observable permission status so the pills confirm — or revert — on their own. + .task { + while !Task.isCancelled { + refreshPermissionState() + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + // Re-check the moment the user returns from System Settings (the most common way a + // permission changes), and on the system's accessibility-changed broadcast. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + refreshPermissionState() + } + .onReceive(DistributedNotificationCenter.default().publisher( + for: Notification.Name("com.apple.accessibility.api"))) + { _ in + refreshPermissionState() + } + } + + // Beneath the tiles: an intro line, then the two permissions the Pro picker needs, side + // by side. Each is a standard button that requests the permission, then flips to a + // non-clickable green "granted" button. Screen Recording is required; Accessibility is + // optional (for global keys). + @ViewBuilder private var permissionArea: some View { + VStack(spacing: 10.0) { + Divider() + Text(PikaText.textPickerPermissionsIntro) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10.0) { + screenRecordingPill + accessibilityPill + } + } + } - Button(action: { - NSApp.sendAction(#selector(AppDelegate.closeSplashWindow), to: nil, from: nil) - }, label: { Text(PikaText.textSplashStart) }) - .keyboardShortcut(.defaultAction) - .tint(Color.accentColor) + @ViewBuilder private var screenRecordingPill: some View { + if hasPermission { + grantedButton(PikaText.textPickerPermScreenRecording) + } else if pendingRelaunch { + // A first-time grant only takes effect after relaunch, so offer that instead. + actionButton(PikaText.textPickerRelaunchButton, systemImage: "arrow.clockwise") { + CustomColorPickSession.relaunch() } - .frame(maxWidth: .infinity, maxHeight: 50.0) + } else { + actionButton(PikaText.textPickerPermScreenRecording, systemImage: "lock") { + Self.requestAccess(pendingRelaunch: $pendingRelaunch) + } + } + } + + @ViewBuilder private var accessibilityPill: some View { + if hasAccessibility { + grantedButton(PikaText.textPickerPermAccessibility) + } else { + actionButton(PikaText.textPickerPermAccessibility, systemImage: "lock") { + Self.requestAccessibility() + } + } + } + + // A regular button that requests a permission (or relaunches). + private func actionButton(_ label: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(label, systemImage: systemImage).frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + + // A flat, non-clickable green "success" state confirming a permission is granted — knocked + // back next to the raised action buttons. + private func grantedButton(_ label: String) -> some View { + Label(label, systemImage: "checkmark.circle.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.green) + .frame(maxWidth: .infinity, minHeight: 29.0) + .background( + RoundedRectangle(cornerRadius: 6.0, style: .continuous) + .fill(Color.green.opacity(0.12)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6.0, style: .continuous) + .strokeBorder(Color.green.opacity(0.4), lineWidth: 1.0) + ) + } + + // Requests Screen Recording permission and persists the intent (`.custom`) so that once + // permission is live — which needs a relaunch — the picker is already selected. Shared + // with the splash's "Get started" nudge so both drive the same state. + static func requestAccess(pendingRelaunch: Binding) { + CustomColorPickSession.requestAccess { granted in + Defaults[.pickerStyle] = .custom + pendingRelaunch.wrappedValue = !granted + // The system only shows its prompt the first time; once the choice has been made + // (e.g. the user revoked it), the request is a no-op — so open the Screen + // Recording pane directly so they can toggle it back on. + if !granted, let url = URL(string: + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + { + NSWorkspace.shared.open(url) + } + } + } + + // Prompts for the optional Accessibility permission (System Settings → Privacy & + // Security → Accessibility). It takes effect live — the global key monitor starts + // firing once trusted — so no relaunch is needed. + static func requestAccessibility() { + // The Accessibility prompt is a regular app alert (unlike the Screen Recording + // system dialog, which sits above floating windows), so a floating splash/Settings + // window would cover it. Drop the key window to normal level for the prompt and + // restore its floating level once the user returns to it. + if let window = NSApp.keyWindow, window.level == .floating { + window.level = .normal + var token: NSObjectProtocol? + token = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main + ) { _ in + window.level = Defaults[.appFloating] ? .floating : .normal + if let token { NotificationCenter.default.removeObserver(token) } + } + } + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + _ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary) + } +} + +/// A selectable comparison tile: a static preview mock of a picker plus its title. System +/// is always selectable; Custom is disabled until Screen Recording permission exists. Not a +/// live capture — the mocks are illustrative. +private enum PickerPreviewKind { case system, custom } + +private struct PickerComparisonTile: View { + let style: PickerPreviewKind + let title: String + let description: String + let selected: Bool + let disabled: Bool + var badge: String? = nil + var footnote: (icon: String, text: String)? = nil + let onSelect: () -> Void + + var body: some View { + VStack(spacing: 6.0) { + Button(action: onSelect, label: { + preview.frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .topTrailing) { + if let badge { badgeView(badge) } + } + }) + .buttonStyle(AppearanceButtonStyle(title: title, description: description, selected: selected)) + .disabled(disabled) + .opacity(disabled ? 0.4 : 1.0) + .animation(.easeInOut(duration: 0.18), value: selected) + + // Optional footnote (e.g. the Pro picker's Screen Recording requirement). The + // tiles top-align, so a footnote just extends its tile lower. + if let footnote { + HStack(spacing: 4.0) { + Image(systemName: footnote.icon).font(.system(size: 9.0)) + Text(footnote.text).font(.system(size: 10.0)) + } + .foregroundStyle(.secondary) + } + } + } + + private func badgeView(_ text: String) -> some View { + Text(text) + .font(.system(size: 9.0, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 5.0) + .padding(.vertical, 2.0) + .background(Capsule().fill(Color.accentColor)) + .padding(6.0) + } + + @ViewBuilder private var preview: some View { + switch style { + case .system: basicMock + case .custom: proMock + } + } + + // Monochrome line-art, matching the app-mode preview art: translucent fills, no colour, + // no container borders. Drawn in `.primary` rather than white so it inverts with the + // appearance — `AppearanceButtonStyle` fades the tile to near-white in light mode, where + // white-on-white left the art invisible. + + // Basic picker: a plain, dull loupe — a single flat disc with a faint crosshair. + private var basicMock: some View { + ZStack { + Circle().fill(Color.primary.opacity(0.08)) + crosshair(opacity: 0.35) + } + .frame(width: 40.0, height: 40.0) + } + + // Pro picker: a mini loupe with a brighter sample area, skeleton readout bars for the + // format and contrast, and a pass dot — richer, showing off the live overlay. + private var proMock: some View { + VStack(spacing: 3.0) { + ZStack { + RoundedRectangle(cornerRadius: 3.0, style: .continuous) + .fill(Color.primary.opacity(0.12)) + crosshair(opacity: 0.35) + } + .frame(height: 18.0) + + HStack(spacing: 4.0) { + skeletonBar(width: 24.0, opacity: 0.3) + Spacer(minLength: 0.0) + Circle().fill(Color.primary.opacity(0.35)).frame(width: 5.0, height: 5.0) + } + } + .padding(.horizontal, 5.0) + .padding(.top, 5.0) + .padding(.bottom, 3.0) + .frame(width: 52.0) + .background( + RoundedRectangle(cornerRadius: 6.0, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + } + + private func crosshair(opacity: Double) -> some View { + Rectangle() + .strokeBorder(Color.primary.opacity(opacity), lineWidth: 2.0) + .frame(width: 9.0, height: 9.0) + } + + private func skeletonBar(width: CGFloat, opacity: Double) -> some View { + Capsule().fill(Color.primary.opacity(opacity)).frame(width: width, height: 3.0) + } +} + +/// Reports the hosting `NSWindow` up to SwiftUI so the permission flow can lower the +/// splash's window level while the system TCC dialog is shown. +private struct SplashWindowAccessor: NSViewRepresentable { + @Binding var window: NSWindow? + + func makeNSView(context _: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { window = view.window } + return view + } + + func updateNSView(_ nsView: NSView, context _: Context) { + DispatchQueue.main.async { + if window == nil { window = nsView.window } } } } diff --git a/PikaTests/ClosestVectorTests.swift b/PikaTests/ClosestVectorTests.swift index 812d1c27..69d1ff89 100644 --- a/PikaTests/ClosestVectorTests.swift +++ b/PikaTests/ClosestVectorTests.swift @@ -69,4 +69,27 @@ final class ClosestVectorTests: XCTestCase { let p3Red = NSColor(colorSpace: .displayP3, components: [1.0, 0.0, 0.0, 1.0], count: 4) XCTAssertEqual(cv.compare(p3Red), 1) } + + // MARK: - compare() quantisation (truncate vs round) + + func test_compare_halfValueComponent_roundsNotTruncates() { + // The named-color database is built with round() (see toRGB8BitArray); the + // query must quantise the same way. 0.5 * 255 = 127.5 → round = 128, + // truncate = 127. With truncation the (0.5, 0.5, 0.5) query wrongly matches + // the [127] bucket, so the picked color is mis-named at every k.5/255 channel. + let cv = ClosestVector([[127, 127, 127], [128, 128, 128]]) + let midGray = NSColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(cv.compare(midGray), 1, + "0.5-channel values must round (128) to match the database convention, not truncate (127)") + } + + func test_compare_halfChannelAsymmetric_roundsNotTruncates() { + // Only the red channel sits on the 0.5 boundary (0.5 * 255 = 127.5 → round + // 128, truncate 127). Truncation wrongly matches the [127, 0, 0] bucket; + // rounding matches [128, 0, 0]. Locks the per-channel (non-gray) behaviour. + let cv = ClosestVector([[127, 0, 0], [128, 0, 0]]) + let halfRed = NSColor(red: 0.5, green: 0.0, blue: 0.0, alpha: 1).usingColorSpace(.sRGB)! + XCTAssertEqual(cv.compare(halfRed), 1, + "a single 0.5-channel must round to match the database convention") + } } diff --git a/PikaTests/ColorDecompositionTests.swift b/PikaTests/ColorDecompositionTests.swift new file mode 100644 index 00000000..885a9770 --- /dev/null +++ b/PikaTests/ColorDecompositionTests.swift @@ -0,0 +1,174 @@ +import Defaults +@testable import Pika +import XCTest + +final class ColorDecompositionTests: XCTestCase { + override func setUp() { + super.setUp() + // Pin the display space so decompose (which reads Defaults[.colorSpace] for + // rgb/hsb/hsl) is deterministic and matches toFormat. + Defaults[.colorSpace] = .sRGB + } + + private let samples = ["3A7BD5", "E32C88", "000000", "FFFFFF", "808080", "FF8800", "00FF00", "123456"] + private var space: NSColorSpace { Defaults[.colorSpace] } + + // MARK: - Parity: decompose(...).joined() must equal toFormat(...) + + func test_decompose_joined_matchesToFormat_forEveryFormatAndStyle() { + for hex in samples { + let color = NSColor(hex: hex) + for format in ColorFormat.allCases { + for style in CopyFormat.allCases { + let expected = color.toFormat(format: format, style: style) + let decomposed = format.decompose(color, style: style, in: space) + XCTAssertEqual( + decomposed.joined(), expected, + "Parity failed for \(hex) format=\(format) style=\(style)" + ) + } + } + } + } + + func test_decompose_componentCount_isOneForHex_threeOtherwise() { + let color = NSColor(hex: "3A7BD5") + for style in CopyFormat.allCases { + XCTAssertEqual(ColorFormat.hex.decompose(color, style: style, in: space).components.count, 1) + for format in ColorFormat.allCases where format != .hex { + XCTAssertEqual( + format.decompose(color, style: style, in: space).components.count, 3, + "\(format)/\(style) should have 3 components" + ) + } + } + } + + // MARK: - Round-trip: decompose → recompose lands on (near) the same colour + + func test_decompose_recompose_roundTrip_withinTolerance() { + for hex in samples { + let color = NSColor(hex: hex) + for format in ColorFormat.allCases { + for style in CopyFormat.allCases { + let decomposed = format.decompose(color, style: style, in: space) + guard let rebuilt = format.recompose(decomposed.values, style: style, in: space) else { + XCTFail("recompose returned nil for \(hex) \(format)/\(style)") + continue + } + let o = color.toRGBAComponents(in: .sRGB) + let r = rebuilt.toRGBAComponents(in: .sRGB) + // hex/rgb are exact 8-bit; hsb/hsl/lab/oklch snap by up to ~1 display unit. + let tol: CGFloat = (format == .hex || format == .rgb) ? 0.01 : 0.02 + XCTAssertEqual(r.r, o.r, accuracy: tol, "R \(hex) \(format)/\(style)") + XCTAssertEqual(r.g, o.g, accuracy: tol, "G \(hex) \(format)/\(style)") + XCTAssertEqual(r.b, o.b, accuracy: tol, "B \(hex) \(format)/\(style)") + } + } + } + } + + // MARK: - recompose rejects bad input + + func test_recompose_nonNumeric_returnsNil() { + XCTAssertNil(ColorFormat.rgb.recompose(["255", "abc", "0"], style: .css, in: space)) + XCTAssertNil(ColorFormat.hsl.recompose(["", "50", "50"], style: .css, in: space)) + XCTAssertNil(ColorFormat.hex.recompose(["nothex"], style: .css, in: space)) + } + + func test_recompose_wrongComponentCount_returnsNil() { + XCTAssertNil(ColorFormat.rgb.recompose(["255", "0"], style: .css, in: space)) + XCTAssertNil(ColorFormat.oklch.recompose(["50"], style: .css, in: space)) + } + + func test_recompose_outOfRange_clampsRatherThanFails() { + // Over-range ints still produce a colour (clamped), not nil. + let white = ColorFormat.rgb.recompose(["999", "999", "999"], style: .css, in: space) + XCTAssertNotNil(white) + let rgba = white!.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 1.0, accuracy: 0.01) + } + + // Regression test: the committed colour must match what `finalizeValues` snaps the + // field's displayed text to — an out-of-range Lab `l` or OKLCH `l`/`c`/`h` used to be passed + // straight through to `fromLab`/`fromOklch` unclamped, silently committing a colour that + // disagreed with the clamped value shown in the UI. + func test_recompose_outOfRange_clampsForLabAndOklch() { + let labOverRange = ColorFormat.lab.recompose(["1000", "50", "0"], style: .css, in: space) + let labClamped = ColorFormat.lab.recompose(["100", "50", "0"], style: .css, in: space) + XCTAssertNotNil(labOverRange) + XCTAssertEqual( + labOverRange?.toHex(in: .sRGB), labClamped?.toHex(in: .sRGB), + "an out-of-range Lab l should commit the same colour the clamped display value shows" + ) + + let oklchOverRange = ColorFormat.oklch.recompose(["50", "0.1", "400"], style: .css, in: space) + let oklchClamped = ColorFormat.oklch.recompose(["50", "0.1", "360"], style: .css, in: space) + XCTAssertNotNil(oklchOverRange) + XCTAssertEqual( + oklchOverRange?.toHex(in: .sRGB), oklchClamped?.toHex(in: .sRGB), + "an out-of-range OKLCH h must clamp (matching the displayed value), not wrap via cos/sin" + ) + } + + // MARK: - ColorComponent validity + + func test_componentValidity_integerRange() { + let c = ColorComponent(value: "128", kind: .integer, range: 0 ... 255) + XCTAssertTrue(c.isValid("0")) + XCTAssertTrue(c.isValid("255")) + // Out-of-range numbers are still valid input — they're clamped to the nearest bound on + // commit (see `EditableColorValue.finalizeValues`) rather than rejected outright. + XCTAssertTrue(c.isValid("256")) + XCTAssertTrue(c.isValid("-1")) + XCTAssertFalse(c.isValid("12.5")) + XCTAssertFalse(c.isValid("abc")) + XCTAssertFalse(c.isValid("")) + } + + func test_componentValidity_hex() { + let c = ColorComponent(value: "ff0000", kind: .hex, range: nil) + XCTAssertTrue(c.isValid("ff0000")) + XCTAssertTrue(c.isValid("F00")) + XCTAssertFalse(c.isValid("ff00")) + XCTAssertFalse(c.isValid("gggggg")) + } + + // Regression test: pasting a hex value with surrounding whitespace shouldn't flag the + // field invalid or fail to recompose — `isValid` and `recompose` both trim first. + func test_componentValidity_hex_trimsWhitespace() { + let c = ColorComponent(value: "ff0000", kind: .hex, range: nil) + XCTAssertTrue(c.isValid(" ff0000 ")) + XCTAssertNotNil(ColorFormat.hex.recompose([" ff0000 "], style: .css, in: space)) + } + + func test_componentValidity_decimalUnbounded() { + let c = ColorComponent(value: "-12.5", kind: .decimal, range: nil) + XCTAssertTrue(c.isValid("-12.5")) + XCTAssertTrue(c.isValid("100")) + XCTAssertFalse(c.isValid("abc")) + } + + // Same "clamped, not rejected" rule as `.integer` — see test_componentValidity_integerRange. + func test_componentValidity_decimalRange() { + let c = ColorComponent(value: "0.5", kind: .decimal, range: 0 ... 1) + XCTAssertTrue(c.isValid("0")) + XCTAssertTrue(c.isValid("1")) + XCTAssertTrue(c.isValid("1.5")) + XCTAssertTrue(c.isValid("-0.5")) + } + + // `Double("inf")`/`Double("nan")` parse successfully but aren't valid colour values — a nil + // range (e.g. Lab a/b) wouldn't otherwise reject them, so `isValid` must check `isFinite` + // explicitly. Regression test for a non-finite value silently reaching `recompose`. + func test_componentValidity_decimalRejectsInfAndNaN() { + let unbounded = ColorComponent(value: "0", kind: .decimal, range: nil) + XCTAssertFalse(unbounded.isValid("inf")) + XCTAssertFalse(unbounded.isValid("-inf")) + XCTAssertFalse(unbounded.isValid("nan")) + + let bounded = ColorComponent(value: "0", kind: .decimal, range: 0 ... 100) + XCTAssertFalse(bounded.isValid("inf")) + XCTAssertFalse(bounded.isValid("nan")) + } +} diff --git a/PikaTests/EditableColorValueTests.swift b/PikaTests/EditableColorValueTests.swift new file mode 100644 index 00000000..25af871d --- /dev/null +++ b/PikaTests/EditableColorValueTests.swift @@ -0,0 +1,96 @@ +@testable import Pika +import XCTest + +/// Tests the per-pixel drag/scroll/arrow-key scrub sensitivity used by editable colour +/// component fields. Regressions here would show up as a component's range being impossible +/// to traverse smoothly (too coarse) or maxing out in a single pixel of drag (too fine). +final class EditableColorValueTests: XCTestCase { + func test_dragUnitsPerPixel_nilRange_isFlatOneUnitPerPixel() { + XCTAssertEqual(dragUnitsPerPixel(for: nil), 1.0) + } + + func test_dragUnitsPerPixel_hueRange_isReferenceSpanAndFeelsFlat() { + // Hue's 0...360 is the reference span itself, so it should still feel like 1 unit/px. + XCTAssertEqual(dragUnitsPerPixel(for: 0 ... 360), 1.0) + } + + func test_dragUnitsPerPixel_narrowRange_scalesDownProportionally() { + // OKLCH chroma's 0...1 is 360x narrower than the reference span. + XCTAssertEqual(dragUnitsPerPixel(for: 0 ... 1), 1.0 / 360.0) + } + + func test_dragUnitsPerPixel_wideRange_scalesUpProportionally() { + // 0...720 is twice the reference span. + XCTAssertEqual(dragUnitsPerPixel(for: 0 ... 720), 2.0) + } + + func test_stableDecimalPlaces_noDot_defaultsToTwo() { + XCTAssertEqual(ColorComponentField.stableDecimalPlaces(for: "5"), 2) + } + + func test_stableDecimalPlaces_trailingDotNoDigits_clampsUpToTwo() { + XCTAssertEqual(ColorComponentField.stableDecimalPlaces(for: "5."), 2) + } + + func test_stableDecimalPlaces_oneDecimal_clampsUpToTwo() { + XCTAssertEqual(ColorComponentField.stableDecimalPlaces(for: "5.1"), 2) + } + + func test_stableDecimalPlaces_twoDecimals_keepsTwo() { + XCTAssertEqual(ColorComponentField.stableDecimalPlaces(for: "5.12"), 2) + } + + func test_stableDecimalPlaces_fourDecimals_keepsFour() { + XCTAssertEqual(ColorComponentField.stableDecimalPlaces(for: "5.1234"), 4) + } + + func test_stableDecimalPlaces_moreThanFourDecimals_clampsDownToFour() { + XCTAssertEqual(ColorComponentField.stableDecimalPlaces(for: "5.123456"), 4) + } + + // MARK: - Scrub reversibility / gamut clamping + + /// Recomposing from a session's *starting* values is what makes a scrub reversible. Pushing + /// OKLCH chroma past the sRGB gamut clamps the colour, and decomposing that clamped colour + /// reports a different lightness and hue — so feeding each frame's result back in would drag + /// the untouched components along with it and you could never return to where you began. + func test_scrubFromPristineValues_isReversible() { + let start = ["40.68", "0.2173", "264.58"] + let format = ColorFormat.oklch + let space = NSColorSpace.sRGB + + guard let original = format.recompose(start, style: .css, in: space) else { + return XCTFail("expected the starting values to recompose") + } + + // Drag chroma far out of gamut, then back to exactly where it started. + var pushed = start + pushed[1] = "1.0" + guard let clamped = format.recompose(pushed, style: .css, in: space) else { + return XCTFail("expected the out-of-gamut values to recompose") + } + let clampedBack = format.decompose(clamped, style: .css, in: space) + XCTAssertNotEqual(clampedBack.values[0], start[0], "clamping should have moved lightness") + XCTAssertNotEqual(clampedBack.values[2], start[2], "clamping should have moved hue") + + // Returning to the original chroma from the pristine starting values restores the colour. + guard let restored = format.recompose(start, style: .css, in: space) else { + return XCTFail("expected the restored values to recompose") + } + XCTAssertEqual(restored.toHex(in: space), original.toHex(in: space)) + + // Whereas carrying the clamped values forward does not. + var ratcheted = clampedBack.values + ratcheted[1] = start[1] + guard let notRestored = format.recompose(ratcheted, style: .css, in: space) else { + return XCTFail("expected the ratcheted values to recompose") + } + XCTAssertNotEqual(notRestored.toHex(in: space), original.toHex(in: space)) + } + + func test_naturalDecimalPlaces_matchesTheStepItCanResolve() { + XCTAssertEqual(ColorComponentField.naturalDecimalPlaces(forRange: 0 ... 360), 0) + XCTAssertEqual(ColorComponentField.naturalDecimalPlaces(forRange: 0 ... 100), 1) + XCTAssertEqual(ColorComponentField.naturalDecimalPlaces(forRange: 0 ... 1), 3) + } +} diff --git a/PikaTests/ExporterTests.swift b/PikaTests/ExporterTests.swift index ff995bdc..89ad371e 100644 --- a/PikaTests/ExporterTests.swift +++ b/PikaTests/ExporterTests.swift @@ -4,11 +4,11 @@ import XCTest /// Tests the palette-to-JSON export contract. Users rely on this format when /// exporting swatches, so the shape and field names are a public contract. /// -/// `Exporter.toText` and `Exporter.toJSON` take `Eyedropper` values whose -/// initialiser force-unwraps `loadColors()`, which reads `ColorNames.json` -/// from `Bundle.main`. That bundle is not populated in the XCTest host, so -/// those paths are out of reach from a unit test without an app-bundle -/// fixture. Coverage for them belongs in an integration/UI test target. +/// `Exporter.toText` and `Exporter.toJSON` take `Eyedropper` values whose colour +/// names come from `ColorNamesManager`, reading the cached list or the bundled +/// `ColorNames.json` from `Bundle.main`. That bundle is not populated in the XCTest +/// host (names resolve to empty), so those paths are out of reach from a unit test +/// without an app-bundle fixture. Coverage for them belongs in an integration/UI test target. final class ExporterTests: XCTestCase { private func makePair(fg: String, bg: String, date: Date) -> ColorPair { ColorPair(id: UUID(), foregroundHex: fg, backgroundHex: bg, date: date) diff --git a/PikaTests/NSColorHSLTests.swift b/PikaTests/NSColorHSLTests.swift index c444ecc4..b564c2c4 100644 --- a/PikaTests/NSColorHSLTests.swift +++ b/PikaTests/NSColorHSLTests.swift @@ -116,4 +116,48 @@ final class NSColorHSLTests: XCTestCase { let color = NSColor(hex: "3A7BD5") XCTAssertFalse(color.toHSLString().isEmpty) } + + // MARK: - fromHSB / fromHSL — inverse round-trips (colorSpace pinned to sRGB in setUp) + + private let roundTripSamples = ["3A7BD5", "E32C88", "00FF00", "808080", "FF8800", "123456"] + + func test_fromHSB_roundTrip_matchesOriginalRGB() { + for hex in roundTripSamples { + let original = NSColor(hex: hex) + let hsb = original.toHSBComponents() + let rebuilt = NSColor.fromHSB(h: hsb.h, s: hsb.s, b: hsb.b) + let o = original.toRGBAComponents(in: .sRGB) + let r = rebuilt.toRGBAComponents(in: .sRGB) + XCTAssertEqual(r.r, o.r, accuracy: 0.005, "R mismatch for \(hex)") + XCTAssertEqual(r.g, o.g, accuracy: 0.005, "G mismatch for \(hex)") + XCTAssertEqual(r.b, o.b, accuracy: 0.005, "B mismatch for \(hex)") + } + } + + func test_fromHSL_roundTrip_matchesOriginalRGB() { + for hex in roundTripSamples { + let original = NSColor(hex: hex) + let hsl = original.toHSLComponents() + let rebuilt = NSColor.fromHSL(h: hsl.h, s: hsl.s, l: hsl.l) + let o = original.toRGBAComponents(in: .sRGB) + let r = rebuilt.toRGBAComponents(in: .sRGB) + XCTAssertEqual(r.r, o.r, accuracy: 0.005, "R mismatch for \(hex)") + XCTAssertEqual(r.g, o.g, accuracy: 0.005, "G mismatch for \(hex)") + XCTAssertEqual(r.b, o.b, accuracy: 0.005, "B mismatch for \(hex)") + } + } + + func test_fromHSB_zeroSaturation_isGray() { + let rebuilt = NSColor.fromHSB(h: 0.5, s: 0, b: 0.6).toRGBAComponents(in: .sRGB) + XCTAssertEqual(rebuilt.r, 0.6, accuracy: 0.005) + XCTAssertEqual(rebuilt.g, 0.6, accuracy: 0.005) + XCTAssertEqual(rebuilt.b, 0.6, accuracy: 0.005) + } + + func test_fromHSL_zeroSaturation_isGray() { + let rebuilt = NSColor.fromHSL(h: 0.5, s: 0, l: 0.4).toRGBAComponents(in: .sRGB) + XCTAssertEqual(rebuilt.r, 0.4, accuracy: 0.005) + XCTAssertEqual(rebuilt.g, 0.4, accuracy: 0.005) + XCTAssertEqual(rebuilt.b, 0.4, accuracy: 0.005) + } } diff --git a/PikaTests/NSColorInitTests.swift b/PikaTests/NSColorInitTests.swift index 7c3fcb74..e4f6ca37 100644 --- a/PikaTests/NSColorInitTests.swift +++ b/PikaTests/NSColorInitTests.swift @@ -56,6 +56,14 @@ final class NSColorInitTests: XCTestCase { XCTAssertEqual(rgba.b, 0.0, accuracy: 0.01) } + func test_initHex_invalidLength_fallsBackToBlack() { + let color = NSColor(hex: "12345") + let rgba = color.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 0.0, accuracy: 0.01) + XCTAssertEqual(rgba.g, 0.0, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.01) + } + func test_initHex_3Char_expandedCorrectly() { // "F00" should expand to "FF0000" let color = NSColor(hex: "F00") @@ -95,4 +103,58 @@ final class NSColorInitTests: XCTestCase { XCTAssertEqual(lRGBA.g, uRGBA.g, accuracy: 0.001) XCTAssertEqual(lRGBA.b, uRGBA.b, accuracy: 0.001) } + + // MARK: - init(hex:) — invalid input falls back to black (never crashes) + + func test_initHex_nonHexCharacters_fallsBackToBlack() { + let color = NSColor(hex: "ZZZZZZ").toRGBAComponents(in: .sRGB) + XCTAssertEqual(color.r + color.g + color.b, 0.0, accuracy: 0.001) + } + + // MARK: - fromHex(_:) — validating parser (nil on invalid) + + func test_fromHex_valid6Char_parses() { + let color = NSColor.fromHex("FF0000", in: .sRGB) + XCTAssertNotNil(color) + let rgba = color!.toRGBAComponents(in: .sRGB) + XCTAssertEqual(rgba.r, 1.0, accuracy: 0.01) + XCTAssertEqual(rgba.g, 0.0, accuracy: 0.01) + XCTAssertEqual(rgba.b, 0.0, accuracy: 0.01) + } + + func test_fromHex_valid3Char_expands() { + let short = NSColor.fromHex("F00", in: .sRGB)!.toRGBAComponents(in: .sRGB) + let long = NSColor.fromHex("FF0000", in: .sRGB)!.toRGBAComponents(in: .sRGB) + XCTAssertEqual(short.r, long.r, accuracy: 0.001) + XCTAssertEqual(short.g, long.g, accuracy: 0.001) + XCTAssertEqual(short.b, long.b, accuracy: 0.001) + } + + func test_fromHex_withHashPrefix_parses() { + XCTAssertNotNil(NSColor.fromHex("#00FF00", in: .sRGB)) + } + + func test_fromHex_invalidLength_returnsNil() { + XCTAssertNil(NSColor.fromHex("12345", in: .sRGB)) + XCTAssertNil(NSColor.fromHex("", in: .sRGB)) + XCTAssertNil(NSColor.fromHex("1234567", in: .sRGB)) + } + + func test_fromHex_nonHexCharacters_returnsNil() { + XCTAssertNil(NSColor.fromHex("GGGGGG", in: .sRGB)) + XCTAssertNil(NSColor.fromHex("12 45 6", in: .sRGB)) + } + + // Regression test: leading/trailing whitespace (e.g. from pasting) must not make an + // otherwise-valid hex string fail to parse, and must round-trip to the same colour. + func test_fromHex_leadingAndTrailingWhitespace_isTrimmed() { + let untrimmed = NSColor.fromHex(" FF0000 ", in: .sRGB) + let trimmed = NSColor.fromHex("FF0000", in: .sRGB) + XCTAssertNotNil(untrimmed) + XCTAssertEqual( + untrimmed?.toRGBAComponents(in: .sRGB).r, trimmed?.toRGBAComponents(in: .sRGB).r + ) + + XCTAssertNotNil(NSColor.fromHex(" #00FF00 ", in: .sRGB)) + } } diff --git a/PikaTests/NSColorLabTests.swift b/PikaTests/NSColorLabTests.swift index 444eae2e..88fed542 100644 --- a/PikaTests/NSColorLabTests.swift +++ b/PikaTests/NSColorLabTests.swift @@ -147,4 +147,52 @@ final class NSColorLabTests: XCTestCase { "Token '\(token)' has trailing zeros after decimal") } } + + // MARK: - fromLab(l:a:b:) — inverse round-trips + + private let roundTripSamples = ["3A7BD5", "E32C88", "00FF00", "808080", "FF8800", "123456"] + + func test_fromLab_roundTrip_matchesOriginalRGB() { + for hex in roundTripSamples { + let original = NSColor(hex: hex).usingColorSpace(.sRGB)! + let lab = original.toLabComponents() + let rebuilt = NSColor.fromLab(l: lab.l, a: lab.a, b: lab.b).usingColorSpace(.sRGB)! + let o = original.toRGBAComponents(in: .sRGB) + let r = rebuilt.toRGBAComponents(in: .sRGB) + XCTAssertEqual(r.r, o.r, accuracy: 0.01, "R mismatch for \(hex)") + XCTAssertEqual(r.g, o.g, accuracy: 0.01, "G mismatch for \(hex)") + XCTAssertEqual(r.b, o.b, accuracy: 0.01, "B mismatch for \(hex)") + } + } + + func test_fromLab_white_isWhite() { + let rebuilt = NSColor.fromLab(l: 100, a: 0, b: 0).toRGBAComponents(in: .sRGB) + XCTAssertEqual(rebuilt.r, 1.0, accuracy: 0.01) + XCTAssertEqual(rebuilt.g, 1.0, accuracy: 0.01) + XCTAssertEqual(rebuilt.b, 1.0, accuracy: 0.01) + } + + // MARK: - fromOklch(l:c:h:) — inverse round-trips + + func test_fromOklch_roundTrip_matchesOriginalRGB() { + for hex in roundTripSamples { + let original = NSColor(hex: hex).usingColorSpace(.sRGB)! + let oklch = original.toOklchComponents() + let rebuilt = NSColor.fromOklch(l: oklch.l, c: oklch.c, h: oklch.h).usingColorSpace(.sRGB)! + let o = original.toRGBAComponents(in: .sRGB) + let r = rebuilt.toRGBAComponents(in: .sRGB) + XCTAssertEqual(r.r, o.r, accuracy: 0.01, "R mismatch for \(hex)") + XCTAssertEqual(r.g, o.g, accuracy: 0.01, "G mismatch for \(hex)") + XCTAssertEqual(r.b, o.b, accuracy: 0.01, "B mismatch for \(hex)") + } + } + + func test_fromOklch_outOfGamut_clampsToValidRange() { + // A wildly out-of-gamut OKLCH should still produce in-[0,1] channels, not NaN/overflow. + let rebuilt = NSColor.fromOklch(l: 0.7, c: 0.4, h: 30).toRGBAComponents(in: .sRGB) + for channel in [rebuilt.r, rebuilt.g, rebuilt.b] { + XCTAssertGreaterThanOrEqual(channel, 0.0) + XCTAssertLessThanOrEqual(channel, 1.0) + } + } } diff --git a/PikaTests/NSColorLuminanceTests.swift b/PikaTests/NSColorLuminanceTests.swift index 278d67b9..4775ab6a 100644 --- a/PikaTests/NSColorLuminanceTests.swift +++ b/PikaTests/NSColorLuminanceTests.swift @@ -136,4 +136,34 @@ final class NSColorLuminanceTests: XCTestCase { XCTAssertTrue(result.contains("."), "Expected period decimal separator in '\(result)'") XCTAssertFalse(result.contains(","), "Did not expect comma decimal separator in '\(result)'") } + + // MARK: - getUIColor() — legible black/white by WCAG contrast (crossover ~0.179) + + // Disambiguate the two overloads (Color / NSColor) to the NSColor one. + private func ui(_ color: NSColor) -> NSColor { color.getUIColor() } + + func test_getUIColor_white_returnsBlack() { + XCTAssertEqual(ui(NSColor(r: 1, g: 1, b: 1)), .black) + } + + func test_getUIColor_black_returnsWhite() { + XCTAssertEqual(ui(NSColor(r: 0, g: 0, b: 0)), .white) + } + + func test_getUIColor_lightBlue_returnsBlack() { + // Regression for the 0.5 → 0.179 threshold fix: this light blue's WCAG luminance is + // ~0.48 — above the contrast crossover, so black is far more legible (~10:1 vs ~2:1). + // The old 0.5 threshold wrongly returned white here. + XCTAssertEqual(ui(NSColor(r: 90, g: 193, b: 254)), .black) + } + + func test_getUIColor_midGray_returnsBlack() { + // ~0.22 luminance — above the crossover; the old 0.5 threshold returned white. + XCTAssertEqual(ui(NSColor(r: 128, g: 128, b: 128)), .black) + } + + func test_getUIColor_pureBlue_returnsWhite() { + // ~0.07 luminance — below the crossover, so white is more legible. + XCTAssertEqual(ui(NSColor(r: 0, g: 0, b: 255)), .white) + } } diff --git a/PikaTests/NotificationNamesTests.swift b/PikaTests/NotificationNamesTests.swift index 6954d2b5..f547c4db 100644 --- a/PikaTests/NotificationNamesTests.swift +++ b/PikaTests/NotificationNamesTests.swift @@ -11,6 +11,11 @@ final class NotificationNamesTests: XCTestCase { PikaConstants.ncTriggerPickForeground) } + func test_triggerPickPair() { + XCTAssertEqual(Notification.Name.triggerPickPair.rawValue, + PikaConstants.ncTriggerPickPair) + } + func test_triggerPickBackground() { XCTAssertEqual(Notification.Name.triggerPickBackground.rawValue, PikaConstants.ncTriggerPickBackground)