From 6a8477758bd4c49173938732c5043bd365cd2392 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 23 Aug 2025 23:50:04 +0530 Subject: [PATCH] Fully working colorpicker --- .gitignore | 3 +- DynamicIsland/ContentView.swift | 2 + DynamicIsland/DynamicIslandApp.swift | 13 + DynamicIsland/Localizable.xcstrings | 66 ++ .../Shortcuts/ShortcutConstants.swift | 1 + .../ColorPicker/ColorPickedFeedbackView.swift | 66 ++ .../ColorPicker/ColorPickerPanel.swift | 612 ++++++++++++++++++ .../ColorPicker/ColorPickerPopover.swift | 130 ++++ .../Notch/DynamicIslandHeader.swift | 18 + .../Notch/NotchColorPickerView.swift | 351 ++++++++++ .../components/Tabs/TabSelectionView.swift | 5 + DynamicIsland/enums/generic.swift | 1 + .../managers/ColorPickerManager.swift | 199 ++++++ .../managers/ColorPickerManager_Fixed.swift | 0 DynamicIsland/models/Constants.swift | 4 + DynamicIsland/models/PickedColor.swift | 198 ++++++ 16 files changed, 1668 insertions(+), 1 deletion(-) create mode 100644 DynamicIsland/components/ColorPicker/ColorPickedFeedbackView.swift create mode 100644 DynamicIsland/components/ColorPicker/ColorPickerPanel.swift create mode 100644 DynamicIsland/components/ColorPicker/ColorPickerPopover.swift create mode 100644 DynamicIsland/components/Notch/NotchColorPickerView.swift create mode 100644 DynamicIsland/managers/ColorPickerManager.swift create mode 100644 DynamicIsland/managers/ColorPickerManager_Fixed.swift create mode 100644 DynamicIsland/models/PickedColor.swift diff --git a/.gitignore b/.gitignore index 1ab1c2d..e8fa0fd 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ integration.json # GitHub instructions and agent files # .github/ -rough_note.md \ No newline at end of file +rough_note.md +Solid \ No newline at end of file diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8760a57..9ed11da 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -322,6 +322,8 @@ struct ContentView: View { NotchTimerView() case .stats: NotchStatsView() + case .colorPicker: + NotchColorPickerView() } } .transition(.asymmetric( diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 5c65520..03339da 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -471,6 +471,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + KeyboardShortcuts.onKeyDown(for: .colorPickerPanel) { [weak self] in + guard let self = self else { return } + + // Only execute if shortcuts are enabled + guard Defaults[.enableShortcuts] else { return } + + // Only open color picker panel if the feature is enabled + guard Defaults[.enableColorPickerFeature] else { return } + + // Toggle color picker panel + ColorPickerPanelManager.shared.toggleColorPickerPanel() + } + KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in guard let self = self else { return } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 46713a8..e148944 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -34,6 +34,9 @@ }, "%lld" : { + }, + "%lld colors" : { + }, "%lld%%" : { @@ -139,6 +142,9 @@ }, "Appearance" : { + }, + "Are you sure you want to delete this color from your history?" : { + }, "Automatically check for updates" : { "localizations" : { @@ -283,6 +289,9 @@ }, "Clear Pinned Items" : { + }, + "Click 'Pick Color' or use Cmd+Shift+P to start picking colors" : { + }, "Clipboard" : { @@ -301,6 +310,21 @@ }, "Close" : { + }, + "Color Details" : { + + }, + "Color Formats" : { + + }, + "Color Picked!" : { + + }, + "Color Picker" : { + + }, + "ColorPicker Disabled" : { + }, "Coming soon" : { "localizations" : { @@ -379,6 +403,12 @@ }, "Default: dynamic.m4a" : { + }, + "Delete" : { + + }, + "Delete Color" : { + }, "Description" : { @@ -465,6 +495,9 @@ } } } + }, + "Enable ColorPicker in Settings → ColorPicker" : { + }, "Enable global keyboard shortcuts above to customize your shortcuts." : { @@ -781,6 +814,15 @@ }, "No clipboard history" : { + }, + "No Colors Found" : { + + }, + "No colors match '%@'" : { + + }, + "No Colors Yet" : { + }, "No custom animation available" : { @@ -894,6 +936,15 @@ } } } + }, + "Pick Color" : { + + }, + "Picked %@" : { + + }, + "Picked Color" : { + }, "Pin items from history to save them here" : { @@ -949,6 +1000,9 @@ } } } + }, + "Recent Colors" : { + }, "Release name" : { "localizations" : { @@ -980,6 +1034,9 @@ }, "Search clipboard..." : { + }, + "Search colors..." : { + }, "Search..." : { @@ -997,6 +1054,9 @@ }, "Seconds" : { + }, + "Select a color to view details" : { + }, "Select Calendars" : { @@ -1050,6 +1110,9 @@ } } } + }, + "Show Color History" : { + }, "Show cool face animation while inactivity" : { "extractionState" : "stale", @@ -1115,6 +1178,9 @@ }, "Start Monitoring" : { + }, + "Start picking colors to build your history" : { + }, "Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : { diff --git a/DynamicIsland/Shortcuts/ShortcutConstants.swift b/DynamicIsland/Shortcuts/ShortcutConstants.swift index 5c51fb9..dc65e93 100644 --- a/DynamicIsland/Shortcuts/ShortcutConstants.swift +++ b/DynamicIsland/Shortcuts/ShortcutConstants.swift @@ -10,6 +10,7 @@ import SwiftUI extension KeyboardShortcuts.Name { static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command])) + static let colorPickerPanel = Self("colorPickerPanel", default: .init(.p, modifiers: [.shift, .command])) static let statsPanel = Self("statsPanel", default: .init(.s, modifiers: [.shift, .command])) static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function])) static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command])) diff --git a/DynamicIsland/components/ColorPicker/ColorPickedFeedbackView.swift b/DynamicIsland/components/ColorPicker/ColorPickedFeedbackView.swift new file mode 100644 index 0000000..543c9da --- /dev/null +++ b/DynamicIsland/components/ColorPicker/ColorPickedFeedbackView.swift @@ -0,0 +1,66 @@ +// +// ColorPickedFeedbackView.swift +// DynamicIsland +// +// Created by GitHub Copilot on 14/08/25. +// + +import SwiftUI +import Defaults + +struct ColorPickedFeedbackView: View { + let color: PickedColor + @Binding var isShowing: Bool + + var body: some View { + VStack(spacing: 12) { + // Color preview + RoundedRectangle(cornerRadius: 12) + .fill(color.color) + .frame(width: 60, height: 60) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.8), lineWidth: 2) + ) + .shadow(color: .black.opacity(0.3), radius: 8) + + // Color info + VStack(spacing: 4) { + Text("Color Picked!") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Text(color.hexString) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .cornerRadius(6) + } + } + .padding(16) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(16) + .shadow(color: .black.opacity(0.3), radius: 12) + .scaleEffect(isShowing ? 1.0 : 0.8) + .opacity(isShowing ? 1.0 : 0.0) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isShowing) + .onTapGesture { + // Copy to clipboard on tap + ColorPickerManager.shared.copyToClipboard(color.hexString) + + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + } + } +} + +#Preview { + ColorPickedFeedbackView( + color: PickedColor(nsColor: NSColor.blue, point: CGPoint(x: 100, y: 100)), + isShowing: .constant(true) + ) + .frame(width: 300, height: 200) +} diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift new file mode 100644 index 0000000..1f5611b --- /dev/null +++ b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift @@ -0,0 +1,612 @@ +// +// ColorPickerPanel.swift +// DynamicIsland +// +// Created by GitHub Copilot on 14/08/25. +// + +import SwiftUI +import Cocoa +import Defaults + +class ColorPickerPanelManager: ObservableObject { + static let shared = ColorPickerPanelManager() + + private var panel: ColorPickerPanel? + + private init() {} + + func showColorPickerPanel() { + hideColorPickerPanel() // Close any existing panel + + let newPanel = ColorPickerPanel() + newPanel.positionPanel() + + self.panel = newPanel + + // Make the panel key and order front to ensure it can receive focus + newPanel.makeKeyAndOrderFront(nil) + newPanel.orderFrontRegardless() + + // Activate the app to ensure proper focus handling + NSApp.activate(ignoringOtherApps: true) + + // Ensure the panel becomes the key window for input + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + newPanel.makeKey() + } + + print("ColorPicker: Panel shown and positioned") + } + + func hideColorPickerPanel() { + panel?.close() + panel = nil + print("ColorPicker: Panel hidden") + } + + func toggleColorPickerPanel() { + if let panel = panel, panel.isVisible { + hideColorPickerPanel() + } else { + showColorPickerPanel() + } + } + + var isPanelVisible: Bool { + return panel?.isVisible ?? false + } +} + +class ColorPickerPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 450, height: 600), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + setupPanel() + } + + private func setupPanel() { + title = "Color Picker" + level = .floating + isMovableByWindowBackground = true + backgroundColor = NSColor.clear + isOpaque = false + hasShadow = true + + titlebarAppearsTransparent = true + titleVisibility = .hidden + + let panelView = ColorPickerPanelView { + ColorPickerPanelManager.shared.hideColorPickerPanel() + } + + contentView = NSHostingView(rootView: panelView) + } + + func positionPanel() { + guard let screen = NSScreen.main else { return } + + let screenFrame = screen.visibleFrame + let panelFrame = frame + + let xPosition = screenFrame.midX - panelFrame.width / 2 + let yPosition = screenFrame.midY - panelFrame.height / 2 + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + print("ColorPicker: Panel positioned at \(xPosition), \(yPosition)") + } +} + +struct ColorPickerPanelView: View { + let onClose: () -> Void + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @State private var searchText = "" + @State private var selectedColor: PickedColor? + @State private var showingDeleteAlert = false + @State private var colorToDelete: PickedColor? + + var filteredColors: [PickedColor] { + if searchText.isEmpty { + return colorPickerManager.colorHistory + } else { + return colorPickerManager.colorHistory.filter { color in + color.hexString.localizedCaseInsensitiveContains(searchText) || + color.rgbString.localizedCaseInsensitiveContains(searchText) || + color.hslString.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + headerSection + + Divider() + .background(Color.gray.opacity(0.3)) + + contentSection + } + .background(ColorPickerVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .alert("Delete Color", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { + if let color = colorToDelete { + colorPickerManager.removeColor(color) + } + } + } message: { + Text("Are you sure you want to delete this color from your history?") + } + } + + private var headerSection: some View { + VStack(spacing: 12) { + // Title and controls + HStack { + HStack(spacing: 8) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text("Color Picker") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + } + + Spacer() + + // Controls + HStack(spacing: 8) { + // Pick Color Button + Button(action: { + colorPickerManager.startColorPicking() + onClose() // Close panel when starting to pick + }) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 14, weight: .medium)) + Text("Pick Color") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + + // Clear History Button + Button(action: { + colorPickerManager.clearHistory() + }) { + Image(systemName: "trash") + .font(.system(size: 14)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + .disabled(colorPickerManager.colorHistory.isEmpty) + + // Close Button + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + } + } + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 14)) + + TextField("Search colors...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 14)) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + .padding(16) + } + + private var contentSection: some View { + HSplitView { + // Left side - Color list + colorListSection + .frame(minWidth: 200) + + // Right side - Color details + colorDetailsSection + .frame(minWidth: 220) + } + .frame(maxHeight: .infinity) + } + + private var colorListSection: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Recent Colors") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + Text("\(filteredColors.count)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.gray.opacity(0.2)) + .cornerRadius(4) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.05)) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Color list + if filteredColors.isEmpty { + emptyStateView + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(filteredColors) { color in + ColorListRow( + color: color, + isSelected: selectedColor?.id == color.id, + onSelect: { selectedColor = color }, + onDelete: { + colorToDelete = color + showingDeleteAlert = true + } + ) + } + } + } + } + } + } + + private var colorDetailsSection: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Color Details") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.05)) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Details content + if let color = selectedColor { + ColorDetailsView(color: color) + } else { + noSelectionView + } + } + } + + private var emptyStateView: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "eyedropper") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + VStack(spacing: 8) { + Text("No Colors Found") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + if searchText.isEmpty { + Text("Start picking colors to build your history") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } else { + Text("No colors match '\(searchText)'") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + private var noSelectionView: some View { + VStack(spacing: 16) { + Spacer() + + Image(systemName: "hand.point.up.left") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + Text("Select a color to view details") + .font(.system(size: 14)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } +} + +struct ColorListRow: View { + let color: PickedColor + let isSelected: Bool + let onSelect: () -> Void + let onDelete: () -> Void + @State private var isHovered = false + + var body: some View { + HStack(spacing: 12) { + // Color preview + Circle() + .fill(color.color) + .frame(width: 32, height: 32) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: 1) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.2), lineWidth: 1) + ) + + // Color info + VStack(alignment: .leading, spacing: 2) { + Text(color.hexString) + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundColor(.primary) + + Text(timeAgoString(from: color.timestamp)) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + Spacer() + + // Actions (shown on hover) + if isHovered { + HStack(spacing: 4) { + Button(action: { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(color.hexString, forType: .string) + + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(.blue) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: 12)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Rectangle() + .fill(isSelected ? Color.blue.opacity(0.2) : (isHovered ? Color.gray.opacity(0.1) : Color.clear)) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + .onTapGesture { + onSelect() + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "Just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h ago" + } else { + let days = Int(interval / 86400) + return "\(days)d ago" + } + } +} + +struct ColorDetailsView: View { + let color: PickedColor + @Default(.showColorFormats) var showColorFormats + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Large color preview + VStack(spacing: 12) { + Rectangle() + .fill(color.color) + .frame(height: 80) + .overlay( + Rectangle() + .stroke(Color.white.opacity(0.8), lineWidth: 2) + ) + .cornerRadius(8) + .shadow(color: .black.opacity(0.2), radius: 4) + + Text("Picked \(timeAgoString(from: color.timestamp))") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + // Color formats + VStack(alignment: .leading, spacing: 8) { + Text("Color Formats") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + ForEach(color.allFormats) { format in + ColorFormatDetailRow(format: format, color: color) + } + } + + Spacer() + } + .padding(16) + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes) minute\(minutes == 1 ? "" : "s") ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours) hour\(hours == 1 ? "" : "s") ago" + } else { + let days = Int(interval / 86400) + return "\(days) day\(days == 1 ? "" : "s") ago" + } + } +} + +struct ColorFormatDetailRow: View { + let format: ColorFormat + let color: PickedColor + @State private var isHovered = false + @State private var justCopied = false + + var body: some View { + HStack(spacing: 8) { + Text(format.name) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 60, alignment: .leading) + + Text(format.value) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + Button(action: copyFormat) { + Image(systemName: justCopied ? "checkmark.circle.fill" : "doc.on.doc") + .font(.system(size: 12)) + .foregroundColor(justCopied ? .green : .blue) + } + .buttonStyle(PlainButtonStyle()) + .opacity(isHovered || justCopied ? 1 : 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.white.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + .onTapGesture { + copyFormat() + } + } + + private func copyFormat() { + ColorPickerManager.shared.copyColorToClipboard(color, format: format) + + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = false + } + } + } +} + +// Helper view for visual effects +struct ColorPickerVisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +#Preview { + ColorPickerPanelView { + print("Close panel") + } + .frame(width: 450, height: 600) + .onAppear { + ColorPickerManager.shared.colorHistory = PickedColor.sampleColors + } +} diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPopover.swift b/DynamicIsland/components/ColorPicker/ColorPickerPopover.swift new file mode 100644 index 0000000..bef2944 --- /dev/null +++ b/DynamicIsland/components/ColorPicker/ColorPickerPopover.swift @@ -0,0 +1,130 @@ +// +// ColorPickerPopover.swift +// DynamicIsland +// +// Created by GitHub Copilot on 14/08/25. +// + +import SwiftUI +import Defaults + +struct ColorPickerPopover: View { + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 16) { + // Header + HStack { + HStack(spacing: 8) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text("Color Picker") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + } + + Spacer() + + Button(action: { + dismiss() + }) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + } + + // Main actions + VStack(spacing: 12) { + // Pick Color Button + Button(action: { + colorPickerManager.startColorPicking() + dismiss() // Close popover when starting to pick + }) { + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 14, weight: .medium)) + Text("Pick Color") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.blue) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + + // Show Panel Button + Button(action: { + ColorPickerPanelManager.shared.showColorPickerPanel() + dismiss() + }) { + HStack(spacing: 8) { + Image(systemName: "rectangle.grid.2x2") + .font(.system(size: 14, weight: .medium)) + Text("Show Color History") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.gray.opacity(0.2)) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + } + + // Recent colors preview (if any) + if !colorPickerManager.colorHistory.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Recent Colors") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 6), spacing: 4) { + ForEach(Array(colorPickerManager.colorHistory.prefix(12))) { color in + Button(action: { + colorPickerManager.copyToClipboard(color.hexString) + + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + + dismiss() + }) { + Circle() + .fill(color.color) + .frame(width: 24, height: 24) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: 1) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.2), lineWidth: 1) + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + } + } + .padding(16) + .frame(width: 240) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + } +} + +#Preview { + ColorPickerPopover() + .onAppear { + ColorPickerManager.shared.colorHistory = PickedColor.sampleColors + } +} diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 9612a12..f715fef 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -92,6 +92,24 @@ struct DynamicIslandHeader: View { } } + // ColorPicker button + if Defaults[.enableColorPickerFeature] { + Button(action: { + ColorPickerPanelManager.shared.toggleColorPickerPanel() + }) { + Capsule() + .fill(.black) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: "eyedropper") + .foregroundColor(.white) + .padding() + .imageScale(.medium) + } + } + .buttonStyle(PlainButtonStyle()) + } + if Defaults[.settingsIconInNotch] { Button(action: { SettingsWindowController.shared.showWindow() diff --git a/DynamicIsland/components/Notch/NotchColorPickerView.swift b/DynamicIsland/components/Notch/NotchColorPickerView.swift new file mode 100644 index 0000000..a76e1d0 --- /dev/null +++ b/DynamicIsland/components/Notch/NotchColorPickerView.swift @@ -0,0 +1,351 @@ +// +// NotchColorPickerView.swift +// DynamicIsland +// +// Created by GitHub Copilot on 14/08/25. +// + +import SwiftUI +import Defaults + +struct NotchColorPickerView: View { + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @Default(.enableColorPickerFeature) var enableColorPickerFeature + @State private var hoveredColorId: UUID? + @State private var showColorInfo: Bool = false + @State private var selectedColor: PickedColor? + + var body: some View { + if !enableColorPickerFeature { + disabledStateView + } else { + colorPickerContent + } + } + + private var disabledStateView: some View { + VStack(spacing: 12) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 32)) + .foregroundColor(.secondary) + + Text("ColorPicker Disabled") + .font(.headline) + .foregroundColor(.primary) + + Text("Enable ColorPicker in Settings → ColorPicker") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var colorPickerContent: some View { + VStack(spacing: 16) { + headerSection + + if colorPickerManager.colorHistory.isEmpty { + emptyStateView + } else { + colorHistoryGrid + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + colorInfoOverlay, + alignment: .topTrailing + ) + } + + private var headerSection: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Image(systemName: "eyedropper.halffull") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text("Recent Colors") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + } + + Text("\(colorPickerManager.colorHistory.count) colors") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + + Spacer() + + // Pick Color Button + Button(action: { + colorPickerManager.startColorPicking() + }) { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 12, weight: .medium)) + Text("Pick Color") + .font(.system(size: 12, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.blue) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .onHover { isHovering in + if isHovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + } + } + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Image(systemName: "eyedropper") + .font(.system(size: 28)) + .foregroundColor(.secondary) + + Text("No Colors Yet") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text("Click 'Pick Color' or use Cmd+Shift+P to start picking colors") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + } + + private var colorHistoryGrid: some View { + LazyVGrid(columns: gridColumns, spacing: 12) { + ForEach(colorPickerManager.colorHistory.prefix(15)) { color in + ColorCircleView( + color: color, + isHovered: hoveredColorId == color.id + ) + .onHover { isHovering in + withAnimation(.easeInOut(duration: 0.2)) { + if isHovering { + hoveredColorId = color.id + selectedColor = color + showColorInfo = true + } else { + hoveredColorId = nil + if selectedColor?.id == color.id { + showColorInfo = false + selectedColor = nil + } + } + } + } + .onTapGesture { + // Copy hex color to clipboard + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(color.hexString, forType: .string) + + // Provide haptic feedback + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + } + } + } + .padding(.horizontal, 8) + } + + private var gridColumns: [GridItem] { + let availableWidth: CGFloat = 400 // Approximate notch content width + let circleSize: CGFloat = 32 + let spacing: CGFloat = 12 + let maxColumns = Int((availableWidth + spacing) / (circleSize + spacing)) + let columns = min(maxColumns, 8) // Maximum 8 columns + + return Array(repeating: GridItem(.fixed(circleSize), spacing: spacing), count: columns) + } + + private var colorInfoOverlay: some View { + Group { + if showColorInfo, let color = selectedColor { + ColorInfoPopup(color: color) + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } + } + } +} + +struct ColorCircleView: View { + let color: PickedColor + let isHovered: Bool + + var body: some View { + ZStack { + // Color circle + Circle() + .fill(color.color) + .frame(width: 32, height: 32) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: isHovered ? 2 : 1) + ) + .overlay( + Circle() + .stroke(Color.black.opacity(0.2), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: isHovered ? 4 : 2, x: 0, y: isHovered ? 2 : 1) + .scaleEffect(isHovered ? 1.1 : 1.0) + .animation(.easeInOut(duration: 0.2), value: isHovered) + } + .frame(width: 32, height: 32) + } +} + +struct ColorInfoPopup: View { + let color: PickedColor + @Default(.showColorFormats) var showColorFormats + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Header with color preview + HStack(spacing: 8) { + Circle() + .fill(color.color) + .frame(width: 24, height: 24) + .overlay( + Circle() + .stroke(Color.white.opacity(0.8), lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 2) { + Text("Picked Color") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + + Text(timeAgoString(from: color.timestamp)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + Spacer() + } + + Divider() + .background(Color.gray.opacity(0.3)) + + // Color formats + if showColorFormats { + VStack(alignment: .leading, spacing: 6) { + ForEach(color.allFormats.prefix(4)) { format in + ColorFormatRow(format: format, color: color) + } + } + } else { + ColorFormatRow( + format: ColorFormat(name: "HEX", value: color.hexString, copyValue: color.hexString), + color: color + ) + } + } + .padding(12) + .frame(width: 220) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.black.opacity(0.9)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.4), radius: 15, x: 0, y: 8) + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "Just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h ago" + } else { + let days = Int(interval / 86400) + return "\(days)d ago" + } + } +} + +struct ColorFormatRow: View { + let format: ColorFormat + let color: PickedColor + @State private var isHovered: Bool = false + + var body: some View { + HStack(spacing: 8) { + Text(format.name) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 40, alignment: .leading) + + Text(format.value) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + if isHovered { + Button(action: { + ColorPickerManager.shared.copyColorToClipboard(color, format: format) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(.blue) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.white.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + .onTapGesture { + ColorPickerManager.shared.copyColorToClipboard(color, format: format) + } + } +} + +#Preview { + NotchColorPickerView() + .frame(width: 500, height: 200) + .background(Color.black) + .onAppear { + // Add some sample colors for preview + let manager = ColorPickerManager.shared + manager.colorHistory = PickedColor.sampleColors + } +} diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index fbf0246..7035c05 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -19,6 +19,7 @@ struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableStatsFeature) var enableStatsFeature + @Default(.enableColorPickerFeature) var enableColorPickerFeature @Namespace var animation var availableTabs: [TabModel] { @@ -35,6 +36,10 @@ struct TabSelectionView: View { tabs.append(TabModel(label: "Stats", icon: "chart.line.uptrend.xyaxis", view: .stats)) } + if enableColorPickerFeature { + tabs.append(TabModel(label: "ColorPicker", icon: "eyedropper", view: .colorPicker)) + } + return tabs } var body: some View { diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index c6c4f32..e1ae0c6 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -29,6 +29,7 @@ public enum NotchViews { case shelf case timer case stats + case colorPicker } enum SettingsEnum { diff --git a/DynamicIsland/managers/ColorPickerManager.swift b/DynamicIsland/managers/ColorPickerManager.swift new file mode 100644 index 0000000..05262c6 --- /dev/null +++ b/DynamicIsland/managers/ColorPickerManager.swift @@ -0,0 +1,199 @@ +// +// ColorPickerManager.swift +// DynamicIsland +// +// Created by GitHub Copilot on 14/08/25. +// + +import SwiftUI +import Cocoa +import CoreGraphics +import Defaults + +// MARK: - NSColor Extensions from Solid +extension NSColor { + var hexString: String { + let red = Int((redComponent * 255).rounded()) + let green = Int((greenComponent * 255).rounded()) + let blue = Int((blueComponent * 255).rounded()) + let alpha = Int((alphaComponent * 255).rounded()) + + if alpha == 255 { + return String(format: "%02x%02x%02x", red, green, blue) + } else { + return String(format: "%02x%02x%02x%02x", red, green, blue, alpha) + } + } +} + +// MARK: - Color Formatter from Solid +struct ColorFormatter { + static let shared = ColorFormatter() + + func hex( + color: NSColor, + includeHashPrefix: Bool = true, + lowerCaseHex: Bool = false + ) -> String { + let prefix = includeHashPrefix ? "#" : "" + let hex = (prefix + color.hexString) + + if lowerCaseHex { + return hex + } else { + return hex.uppercased() + } + } +} + +class ColorPickerManager: ObservableObject { + static let shared = ColorPickerManager() + + @Published var colorHistory: [PickedColor] = [] + @Published var isPickingColor: Bool = false + @Published var isShowingPanel: Bool = false + @Published var showColorPickedFeedback: Bool = false + @Published var lastPickedColor: PickedColor? + + // Use NSColorSampler from Solid project - much more reliable + private let colorSampler = NSColorSampler() + + private init() { + loadColorHistory() + } + + // MARK: - Public Interface + + func toggleColorPicker() { + if isPickingColor { + stopColorPicking() + } else { + startColorPicking() + } + } + + func startColorPicking() { + guard !isPickingColor else { return } + + print("ColorPicker: Starting color picking with NSColorSampler...") + + isPickingColor = true + isShowingPanel = false + showColorPickedFeedback = false + + // Use NSColorSampler like Solid project - much cleaner and more reliable + colorSampler.show { [weak self] pickedColor in + guard let self = self else { return } + + DispatchQueue.main.async { + self.isPickingColor = false + + if let color = pickedColor { + print("ColorPicker: Successfully picked color - R:\(color.redComponent) G:\(color.greenComponent) B:\(color.blueComponent)") + + // Convert to sRGB for consistent color handling + if let sRGBColor = color.usingColorSpace(.sRGB) { + let pickedColor = PickedColor(nsColor: sRGBColor, point: NSEvent.mouseLocation) + self.addColor(pickedColor) + self.showColorPickedFeedback(for: pickedColor) + + // Provide haptic feedback if enabled + if Defaults[.enableHaptics] { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + } + } else { + print("ColorPicker: Color picking cancelled by user") + } + } + } + } + + func stopColorPicking() { + print("ColorPicker: Stopping color picking...") + isPickingColor = false + } + + func togglePanel() { + isShowingPanel.toggle() + } + + // MARK: - Color Feedback + + private func showColorPickedFeedback(for color: PickedColor) { + print("ColorPicker: Showing color picked feedback for \(color.hexString)") + lastPickedColor = color + showColorPickedFeedback = true + + // Reopen the ColorPicker panel after color is picked + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + ColorPickerPanelManager.shared.showColorPickerPanel() + } + + // Hide feedback after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + print("ColorPicker: Hiding color picked feedback") + self.showColorPickedFeedback = false + } + } + + // MARK: - Color History Management + + func addColor(_ color: PickedColor) { + // Remove if already exists to avoid duplicates + colorHistory.removeAll { $0.id == color.id } + + // Add to beginning of list + colorHistory.insert(color, at: 0) + + // Limit history size + if colorHistory.count > 50 { + colorHistory = Array(colorHistory.prefix(50)) + } + + saveColorHistory() + + // Always copy to clipboard for now (can be made configurable later if needed) + copyToClipboard(color.hexString) + } + + func removeColor(_ color: PickedColor) { + colorHistory.removeAll { $0.id == color.id } + saveColorHistory() + } + + func clearHistory() { + colorHistory.removeAll() + saveColorHistory() + } + + func copyToClipboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + + // Always show confirmation in console for now + print("Copied to clipboard: \(text)") + } + + func copyColorToClipboard(_ color: PickedColor, format: ColorFormat) { + copyToClipboard(format.copyValue) + } + + // MARK: - Persistence + + private func saveColorHistory() { + if let encoded = try? JSONEncoder().encode(colorHistory) { + UserDefaults.standard.set(encoded, forKey: "ColorPickerHistory") + } + } + + private func loadColorHistory() { + guard let data = UserDefaults.standard.data(forKey: "ColorPickerHistory"), + let decoded = try? JSONDecoder().decode([PickedColor].self, from: data) else { + return + } + + colorHistory = decoded + } +} diff --git a/DynamicIsland/managers/ColorPickerManager_Fixed.swift b/DynamicIsland/managers/ColorPickerManager_Fixed.swift new file mode 100644 index 0000000..e69de29 diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 6fc0f36..92dd270 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -186,6 +186,10 @@ extension Defaults.Keys { // MARK: Timer Feature static let enableTimerFeature = Key("enableTimerFeature", default: true) + // MARK: ColorPicker Feature + static let enableColorPickerFeature = Key("enableColorPickerFeature", default: true) + static let showColorFormats = Key("showColorFormats", default: true) + // MARK: Clipboard Feature static let enableClipboardManager = Key("enableClipboardManager", default: true) static let clipboardHistorySize = Key("clipboardHistorySize", default: 3) diff --git a/DynamicIsland/models/PickedColor.swift b/DynamicIsland/models/PickedColor.swift new file mode 100644 index 0000000..63f6e30 --- /dev/null +++ b/DynamicIsland/models/PickedColor.swift @@ -0,0 +1,198 @@ +// +// PickedColor.swift +// DynamicIsland +// +// Created by GitHub Copilot on 14/08/25. +// + +import SwiftUI +import Foundation +import Defaults + +struct PickedColor: Identifiable, Codable, Hashable, Defaults.Serializable { + let id = UUID() + let red: Double + let green: Double + let blue: Double + let alpha: Double + let timestamp: Date + let displayPoint: CGPoint + + // Computed properties for different color formats + var nsColor: NSColor { + NSColor(red: red, green: green, blue: blue, alpha: alpha) + } + + var color: Color { + Color(red: red, green: green, blue: blue, opacity: alpha) + } + + var hexString: String { + let r = Int(red * 255) + let g = Int(green * 255) + let b = Int(blue * 255) + + if alpha < 1.0 { + let a = Int(alpha * 255) + return String(format: "#%02X%02X%02X%02X", r, g, b, a) + } else { + return String(format: "#%02X%02X%02X", r, g, b) + } + } + + var rgbString: String { + let r = Int(red * 255) + let g = Int(green * 255) + let b = Int(blue * 255) + return "rgb(\(r), \(g), \(b))" + } + + var rgbaString: String { + let r = Int(red * 255) + let g = Int(green * 255) + let b = Int(blue * 255) + return String(format: "rgba(%d, %d, %d, %.2f)", r, g, b, alpha) + } + + var hslString: String { + let (h, s, l) = rgbToHsl(red: red, green: green, blue: blue) + return String(format: "hsl(%.0f, %.0f%%, %.0f%%)", h, s * 100, l * 100) + } + + var hslaString: String { + let (h, s, l) = rgbToHsl(red: red, green: green, blue: blue) + return String(format: "hsla(%.0f, %.0f%%, %.0f%%, %.2f)", h, s * 100, l * 100, alpha) + } + + var hsvString: String { + let (h, s, v) = rgbToHsv(red: red, green: green, blue: blue) + return String(format: "hsv(%.0f, %.0f%%, %.0f%%)", h, s * 100, v * 100) + } + + var swiftUIString: String { + return String(format: "Color(red: %.3f, green: %.3f, blue: %.3f, opacity: %.3f)", red, green, blue, alpha) + } + + var uiColorString: String { + return String(format: "UIColor(red: %.3f, green: %.3f, blue: %.3f, alpha: %.3f)", red, green, blue, alpha) + } + + var allFormats: [ColorFormat] { + return [ + ColorFormat(name: "HEX", value: hexString, copyValue: hexString), + ColorFormat(name: "RGB", value: rgbString, copyValue: rgbString), + ColorFormat(name: "RGBA", value: rgbaString, copyValue: rgbaString), + ColorFormat(name: "HSL", value: hslString, copyValue: hslString), + ColorFormat(name: "HSLA", value: hslaString, copyValue: hslaString), + ColorFormat(name: "HSV", value: hsvString, copyValue: hsvString), + ColorFormat(name: "SwiftUI", value: swiftUIString, copyValue: swiftUIString), + ColorFormat(name: "UIColor", value: uiColorString, copyValue: uiColorString) + ] + } + + init(nsColor: NSColor, point: CGPoint) { + let rgba = nsColor.usingColorSpace(.sRGB) ?? nsColor + self.red = Double(rgba.redComponent) + self.green = Double(rgba.greenComponent) + self.blue = Double(rgba.blueComponent) + self.alpha = Double(rgba.alphaComponent) + self.timestamp = Date() + self.displayPoint = point + } + + init(red: Double, green: Double, blue: Double, alpha: Double = 1.0, point: CGPoint) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + self.timestamp = Date() + self.displayPoint = point + } + + // Helper functions for color space conversion + private func rgbToHsl(red: Double, green: Double, blue: Double) -> (h: Double, s: Double, l: Double) { + let max = Swift.max(red, green, blue) + let min = Swift.min(red, green, blue) + + let lightness = (max + min) / 2 + + if max == min { + return (0, 0, lightness) // achromatic + } + + let delta = max - min + let saturation: Double + + if lightness > 0.5 { + saturation = delta / (2 - max - min) + } else { + saturation = delta / (max + min) + } + + var hue: Double + switch max { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0) + case green: + hue = (blue - red) / delta + 2 + case blue: + hue = (red - green) / delta + 4 + default: + hue = 0 + } + + hue *= 60 + + return (hue, saturation, lightness) + } + + private func rgbToHsv(red: Double, green: Double, blue: Double) -> (h: Double, s: Double, v: Double) { + let max = Swift.max(red, green, blue) + let min = Swift.min(red, green, blue) + + let value = max + let delta = max - min + + let saturation = max == 0 ? 0 : delta / max + + if delta == 0 { + return (0, saturation, value) // achromatic + } + + var hue: Double + switch max { + case red: + hue = (green - blue) / delta + (green < blue ? 6 : 0) + case green: + hue = (blue - red) / delta + 2 + case blue: + hue = (red - green) / delta + 4 + default: + hue = 0 + } + + hue *= 60 + + return (hue, saturation, value) + } +} + +struct ColorFormat: Identifiable { + let id = UUID() + let name: String + let value: String + let copyValue: String +} + +// Extension for preview/testing +extension PickedColor { + static var sampleColors: [PickedColor] { + return [ + PickedColor(red: 1.0, green: 0.0, blue: 0.0, point: CGPoint(x: 100, y: 100)), + PickedColor(red: 0.0, green: 1.0, blue: 0.0, point: CGPoint(x: 200, y: 100)), + PickedColor(red: 0.0, green: 0.0, blue: 1.0, point: CGPoint(x: 300, y: 100)), + PickedColor(red: 1.0, green: 1.0, blue: 0.0, point: CGPoint(x: 400, y: 100)), + PickedColor(red: 1.0, green: 0.0, blue: 1.0, point: CGPoint(x: 500, y: 100)) + ] + } +}