From 6a8477758bd4c49173938732c5043bd365cd2392 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 23 Aug 2025 23:50:04 +0530 Subject: [PATCH 1/5] 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)) + ] + } +} From f84c0aafa44cb0432693155de37068f691b64b0c Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 24 Aug 2025 00:06:31 +0530 Subject: [PATCH 2/5] Fix: Window things --- .../components/Clipboard/ClipboardPanel.swift | 91 +++++++----- .../ColorPicker/ColorPickerPanel.swift | 131 +++++++++++++----- 2 files changed, 152 insertions(+), 70 deletions(-) diff --git a/DynamicIsland/components/Clipboard/ClipboardPanel.swift b/DynamicIsland/components/Clipboard/ClipboardPanel.swift index eb2318c..974ea6c 100644 --- a/DynamicIsland/components/Clipboard/ClipboardPanel.swift +++ b/DynamicIsland/components/Clipboard/ClipboardPanel.swift @@ -157,44 +157,66 @@ struct ClipboardPanelView: View { } var body: some View { - VStack(spacing: 0) { - // Header with tabs - ClipboardPanelHeader( - selectedTab: $selectedTab, - searchText: $searchText, - onClose: onClose - ) - - Divider() - .background(Color.gray.opacity(0.3)) - - // Content - if filteredItems.isEmpty { - ClipboardPanelEmptyState( - hasSearch: !searchText.isEmpty, - isHistoryTab: selectedTab == .history + ZStack { + VStack(spacing: 0) { + // Header with tabs + ClipboardPanelHeader( + selectedTab: $selectedTab, + searchText: $searchText, + onClose: onClose ) - } else { - ScrollView { - LazyVStack(spacing: 1) { - ForEach(filteredItems) { item in - ClipboardPanelItemRow( - item: item, - isHovered: hoveredItemId == item.id, - isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) - ) { hoverId in - hoveredItemId = hoverId + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if filteredItems.isEmpty { + ClipboardPanelEmptyState( + hasSearch: !searchText.isEmpty, + isHistoryTab: selectedTab == .history + ) + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardPanelItemRow( + item: item, + isHovered: hoveredItemId == item.id, + isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + ) { hoverId in + hoveredItemId = hoverId + } } } + .padding(.vertical, 8) } - .padding(.vertical, 8) } } + .frame(width: 320, height: 400) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) + + // Close button positioned in top-left corner + VStack { + HStack { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .frame(width: 24, height: 24) + .background(Color.red) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + .padding(.leading, 12) + .padding(.top, 12) + + Spacer() + } + Spacer() + } } - .frame(width: 320, height: 400) - .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) - .cornerRadius(12) - .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) } } @@ -234,13 +256,6 @@ struct ClipboardPanelHeader: View { } .buttonStyle(PlainButtonStyle()) .disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty) - - Button(action: onClose) { - Image(systemName: "xmark") - .foregroundColor(.secondary) - .font(.system(size: 12, weight: .medium)) - } - .buttonStyle(PlainButtonStyle()) } .padding(.horizontal, 16) .padding(.top, 12) diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift index 1f5611b..9ea32ce 100644 --- a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift +++ b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift @@ -20,11 +20,12 @@ class ColorPickerPanelManager: ObservableObject { 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 + // Position the panel + newPanel.positionPanel() + + // Make the panel visible and focused newPanel.makeKeyAndOrderFront(nil) newPanel.orderFrontRegardless() @@ -60,35 +61,52 @@ class ColorPickerPanelManager: ObservableObject { class ColorPickerPanel: NSPanel { override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { false } + override var canBecomeMain: Bool { true } init() { super.init( - contentRect: NSRect(x: 0, y: 0, width: 450, height: 600), - styleMask: [.titled, .closable, .fullSizeContentView], + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, - defer: false + defer: true ) setupPanel() } private func setupPanel() { - title = "Color Picker" - level = .floating - isMovableByWindowBackground = true - backgroundColor = NSColor.clear + backgroundColor = .clear isOpaque = false hasShadow = true - + level = .floating + isMovableByWindowBackground = true titlebarAppearsTransparent = true titleVisibility = .hidden + isFloatingPanel = true + + // Allow dragging from any part of the window + styleMask.insert(.fullSizeContentView) + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary // Float above full-screen apps + ] + + // Accept mouse moved events for proper hover behavior + acceptsMouseMovedEvents = true let panelView = ColorPickerPanelView { ColorPickerPanelManager.shared.hideColorPickerPanel() } - contentView = NSHostingView(rootView: panelView) + let hostingView = NSHostingView(rootView: panelView) + contentView = hostingView + + // Set initial size + let preferredSize = CGSize(width: 450, height: 600) + hostingView.setFrameSize(preferredSize) + setContentSize(preferredSize) } func positionPanel() { @@ -97,11 +115,46 @@ class ColorPickerPanel: NSPanel { let screenFrame = screen.visibleFrame let panelFrame = frame - let xPosition = screenFrame.midX - panelFrame.width / 2 - let yPosition = screenFrame.midY - panelFrame.height / 2 + // Check if we have a saved position + if let savedPosition = getSavedPosition() { + // Validate saved position is still on screen + let savedFrame = NSRect(origin: savedPosition, size: panelFrame.size) + if screenFrame.intersects(savedFrame) { + setFrameOrigin(savedPosition) + return + } + } + + // Default to center of screen (not top center) + let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX + let yPosition = (screenFrame.height - panelFrame.height) / 2 + screenFrame.minY setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) - print("ColorPicker: Panel positioned at \(xPosition), \(yPosition)") + } + + private func getSavedPosition() -> NSPoint? { + let defaults = UserDefaults.standard + let x = defaults.double(forKey: "colorPickerPanelPositionX") + let y = defaults.double(forKey: "colorPickerPanelPositionY") + + // Check if we have valid saved coordinates (not default 0.0) + if x != 0.0 || y != 0.0 { + return NSPoint(x: x, y: y) + } + return nil + } + + private func saveCurrentPosition() { + let currentOrigin = frame.origin + let defaults = UserDefaults.standard + defaults.set(currentOrigin.x, forKey: "colorPickerPanelPositionX") + defaults.set(currentOrigin.y, forKey: "colorPickerPanelPositionY") + } + + override func setFrameOrigin(_ point: NSPoint) { + super.setFrameOrigin(point) + // Save position whenever it changes (user dragging) + saveCurrentPosition() } } @@ -126,16 +179,38 @@ struct ColorPickerPanelView: View { } var body: some View { - VStack(spacing: 0) { - headerSection + ZStack { + VStack(spacing: 0) { + headerSection + + Divider() + .background(Color.gray.opacity(0.3)) + + contentSection + } + .background(ColorPickerVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) - Divider() - .background(Color.gray.opacity(0.3)) - - contentSection + // Close button positioned in top-left corner + VStack { + HStack { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .frame(width: 24, height: 24) + .background(Color.red) + .clipShape(Circle()) + } + .buttonStyle(PlainButtonStyle()) + .padding(.leading, 12) + .padding(.top, 12) + + Spacer() + } + Spacer() + } } - .background(ColorPickerVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) - .cornerRadius(12) .alert("Delete Color", isPresented: $showingDeleteAlert) { Button("Cancel", role: .cancel) { } Button("Delete", role: .destructive) { @@ -195,14 +270,6 @@ struct ColorPickerPanelView: View { } .buttonStyle(PlainButtonStyle()) .disabled(colorPickerManager.colorHistory.isEmpty) - - // Close Button - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.secondary) - } - .buttonStyle(PlainButtonStyle()) } } From f06e14074a4bdeff7abe40d087922210fb3177a1 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 24 Aug 2025 00:13:59 +0530 Subject: [PATCH 3/5] Working --- .../components/Clipboard/ClipboardPanel.swift | 14 ++----- .../ColorPicker/ColorPickerPanel.swift | 38 +++++++++++++------ 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/DynamicIsland/components/Clipboard/ClipboardPanel.swift b/DynamicIsland/components/Clipboard/ClipboardPanel.swift index 974ea6c..905e148 100644 --- a/DynamicIsland/components/Clipboard/ClipboardPanel.swift +++ b/DynamicIsland/components/Clipboard/ClipboardPanel.swift @@ -200,17 +200,9 @@ struct ClipboardPanelView: View { // Close button positioned in top-left corner VStack { HStack { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .frame(width: 24, height: 24) - .background(Color.red) - .clipShape(Circle()) - } - .buttonStyle(PlainButtonStyle()) - .padding(.leading, 12) - .padding(.top, 12) + NativeStyleCloseButton(action: onClose) + .padding(.leading, 8) + .padding(.top, 8) Spacer() } diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift index 9ea32ce..90a6f86 100644 --- a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift +++ b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift @@ -194,17 +194,9 @@ struct ColorPickerPanelView: View { // Close button positioned in top-left corner VStack { HStack { - Button(action: onClose) { - Image(systemName: "xmark") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .frame(width: 24, height: 24) - .background(Color.red) - .clipShape(Circle()) - } - .buttonStyle(PlainButtonStyle()) - .padding(.leading, 12) - .padding(.top, 12) + NativeStyleCloseButton(action: onClose) + .padding(.leading, 8) + .padding(.top, 8) Spacer() } @@ -668,6 +660,30 @@ struct ColorPickerVisualEffectView: NSViewRepresentable { func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } +// MARK: - Native Style Close Button Component +struct NativeStyleCloseButton: View { + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(.white) + .frame(width: 13, height: 13) + .background(isHovered ? Color.red.opacity(0.8) : Color.red) + .clipShape(Circle()) + .scaleEffect(isHovered ? 1.1 : 1.0) + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.1)) { + isHovered = hovering + } + } + } +} + #Preview { ColorPickerPanelView { print("Close panel") From e6eaf933abf952a439d76a133dc8cda47bb41b5d Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 24 Aug 2025 19:17:59 +0530 Subject: [PATCH 4/5] Fully working --- DynamicIsland/Localizable.xcstrings | 57 ++++++++ .../components/Clipboard/ClipboardPanel.swift | 94 ++++++------ .../ColorPicker/ColorPickerPanel.swift | 33 ++--- .../Notch/DynamicIslandHeader.swift | 11 +- .../components/Settings/SettingsView.swift | 136 ++++++++++++++++++ .../components/Tabs/TabSelectionView.swift | 29 ++-- DynamicIsland/models/Constants.swift | 22 +++ 7 files changed, 297 insertions(+), 85 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index e148944..3869f54 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -58,15 +58,27 @@ }, "3 items" : { + }, + "5 colors" : { + }, "5 items" : { }, "7 items" : { + }, + "10 colors" : { + }, "10 items" : { + }, + "15 colors" : { + + }, + "20 colors" : { + }, "About" : { "localizations" : { @@ -283,6 +295,12 @@ }, "Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." : { + }, + "Clear Color History" : { + + }, + "Clear color history removes all picked colors. Start color picking begins screen color capture mode." : { + }, "Clear Data" : { @@ -316,12 +334,21 @@ }, "Color Formats" : { + }, + "Color History" : { + }, "Color Picked!" : { }, "Color Picker" : { + }, + "Color Picker feature is disabled" : { + + }, + "Color Picker Panel:" : { + }, "ColorPicker Disabled" : { @@ -524,6 +551,9 @@ }, "Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view." : { + }, + "Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker." : { + }, "Enable sneak peek" : { @@ -914,12 +944,18 @@ }, "Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled." : { + }, + "Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled." : { + }, "Opens the detailed system performance monitor panel. Default is Cmd+Shift+S. Only works when stats feature is enabled." : { }, "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : { + }, + "Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button." : { + }, "Pause" : { @@ -945,6 +981,9 @@ }, "Picked Color" : { + }, + "Picking Status" : { + }, "Pin items from history to save them here" : { @@ -963,6 +1002,9 @@ }, "Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." : { + }, + "Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window." : { + }, "Progress bar" : { "localizations" : { @@ -976,6 +1018,9 @@ }, "Progressbar style" : { + }, + "Quick Actions" : { + }, "Quit" : { "localizations" : { @@ -1000,6 +1045,9 @@ } } } + }, + "Ready" : { + }, "Recent Colors" : { @@ -1113,6 +1161,9 @@ }, "Show Color History" : { + }, + "Show Color Picker Panel" : { + }, "Show cool face animation while inactivity" : { "extractionState" : "stale", @@ -1172,6 +1223,9 @@ }, "Start" : { + }, + "Start Color Picking" : { + }, "Start Demo Timer:" : { @@ -1205,6 +1259,9 @@ }, "Stats Panel:" : { + }, + "Status & Actions" : { + }, "Stop" : { diff --git a/DynamicIsland/components/Clipboard/ClipboardPanel.swift b/DynamicIsland/components/Clipboard/ClipboardPanel.swift index 905e148..f7c0bbc 100644 --- a/DynamicIsland/components/Clipboard/ClipboardPanel.swift +++ b/DynamicIsland/components/Clipboard/ClipboardPanel.swift @@ -157,58 +157,44 @@ struct ClipboardPanelView: View { } var body: some View { - ZStack { - VStack(spacing: 0) { - // Header with tabs - ClipboardPanelHeader( - selectedTab: $selectedTab, - searchText: $searchText, - onClose: onClose + VStack(spacing: 0) { + // Header with tabs + ClipboardPanelHeader( + selectedTab: $selectedTab, + searchText: $searchText, + onClose: onClose + ) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if filteredItems.isEmpty { + ClipboardPanelEmptyState( + hasSearch: !searchText.isEmpty, + isHistoryTab: selectedTab == .history ) - - Divider() - .background(Color.gray.opacity(0.3)) - - // Content - if filteredItems.isEmpty { - ClipboardPanelEmptyState( - hasSearch: !searchText.isEmpty, - isHistoryTab: selectedTab == .history - ) - } else { - ScrollView { - LazyVStack(spacing: 1) { - ForEach(filteredItems) { item in - ClipboardPanelItemRow( - item: item, - isHovered: hoveredItemId == item.id, - isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) - ) { hoverId in - hoveredItemId = hoverId - } + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardPanelItemRow( + item: item, + isHovered: hoveredItemId == item.id, + isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + ) { hoverId in + hoveredItemId = hoverId } } - .padding(.vertical, 8) } + .padding(.vertical, 8) } } - .frame(width: 320, height: 400) - .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) - .cornerRadius(12) - .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) - - // Close button positioned in top-left corner - VStack { - HStack { - NativeStyleCloseButton(action: onClose) - .padding(.leading, 8) - .padding(.top, 8) - - Spacer() - } - Spacer() - } } + .frame(width: 320, height: 400) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) } } @@ -223,6 +209,9 @@ struct ClipboardPanelHeader: View { VStack(spacing: 8) { // Title and close button HStack { + // Close button + NativeStyleCloseButton(action: onClose) + Image(systemName: "doc.on.clipboard") .foregroundColor(.primary) .font(.system(size: 16, weight: .medium)) @@ -336,6 +325,7 @@ struct ClipboardPanelItemRow: View { let isPinned: Bool let onHover: (UUID?) -> Void @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var justCopied = false var body: some View { HStack(alignment: .top, spacing: 12) { @@ -388,10 +378,20 @@ struct ClipboardPanelItemRow: View { // Copy button Button(action: { clipboardManager.copyToClipboard(item) + + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeInOut(duration: 0.2)) { + justCopied = false + } + } }) { - Image(systemName: "doc.on.doc") + Image(systemName: justCopied ? "checkmark.circle.fill" : "doc.on.doc") .font(.system(size: 11)) - .foregroundColor(.green) + .foregroundColor(justCopied ? .green : .green) } .buttonStyle(PlainButtonStyle()) diff --git a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift index 90a6f86..c00b1a2 100644 --- a/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift +++ b/DynamicIsland/components/ColorPicker/ColorPickerPanel.swift @@ -179,30 +179,16 @@ struct ColorPickerPanelView: View { } var body: some View { - ZStack { - VStack(spacing: 0) { - headerSection - - Divider() - .background(Color.gray.opacity(0.3)) - - contentSection - } - .background(ColorPickerVisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) - .cornerRadius(12) + VStack(spacing: 0) { + headerSection - // Close button positioned in top-left corner - VStack { - HStack { - NativeStyleCloseButton(action: onClose) - .padding(.leading, 8) - .padding(.top, 8) - - Spacer() - } - Spacer() - } + 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) { @@ -219,6 +205,9 @@ struct ColorPickerPanelView: View { VStack(spacing: 12) { // Title and controls HStack { + // Close button + NativeStyleCloseButton(action: onClose) + HStack(spacing: 8) { Image(systemName: "eyedropper.halffull") .font(.system(size: 16, weight: .medium)) diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index f715fef..6f0a2ec 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -16,6 +16,7 @@ struct DynamicIslandHeader: View { @ObservedObject var clipboardManager = ClipboardManager.shared @StateObject var tvm = TrayDrop.shared @State private var showClipboardPopover = false + @State private var showColorPickerPopover = false var body: some View { HStack(spacing: 0) { @@ -95,7 +96,12 @@ struct DynamicIslandHeader: View { // ColorPicker button if Defaults[.enableColorPickerFeature] { Button(action: { - ColorPickerPanelManager.shared.toggleColorPickerPanel() + switch Defaults[.colorPickerDisplayMode] { + case .panel: + ColorPickerPanelManager.shared.toggleColorPickerPanel() + case .popover: + showColorPickerPopover.toggle() + } }) { Capsule() .fill(.black) @@ -108,6 +114,9 @@ struct DynamicIslandHeader: View { } } .buttonStyle(PlainButtonStyle()) + .popover(isPresented: $showColorPickerPopover, arrowEdge: .bottom) { + ColorPickerPopover() + } } if Defaults[.settingsIconInNotch] { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 0471beb..2ae087a 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -59,6 +59,9 @@ struct SettingsView: View { NavigationLink(value: "Clipboard") { Label("Clipboard", systemImage: "clipboard") } + NavigationLink(value: "ColorPicker") { + Label("Color Picker", systemImage: "eyedropper") + } if extensionManager.installedExtensions .contains(where: { $0.bundleIdentifier == downloadManagerExtension }) { NavigationLink(value: "Downloads") { @@ -102,6 +105,8 @@ struct SettingsView: View { StatsSettings() case "Clipboard": ClipboardSettings() + case "ColorPicker": + ColorPickerSettings() case "Downloads": Downloads() case "Shelf": @@ -1116,6 +1121,7 @@ struct Shortcuts: View { @Default(.enableClipboardManager) var enableClipboardManager @Default(.enableShortcuts) var enableShortcuts @Default(.enableStatsFeature) var enableStatsFeature + @Default(.enableColorPickerFeature) var enableColorPickerFeature var body: some View { Form { @@ -1228,6 +1234,29 @@ struct Shortcuts: View { .foregroundStyle(.secondary) .font(.caption) } + + Section { + HStack { + VStack(alignment: .leading) { + KeyboardShortcuts.Recorder("Color Picker Panel:", name: .colorPickerPanel) + .disabled(!enableShortcuts || !enableColorPickerFeature) + if !enableColorPickerFeature { + Text("Color Picker feature is disabled") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + } + Spacer() + } + } header: { + Text("Color Picker") + } footer: { + Text("Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } } else { Section { VStack(alignment: .leading, spacing: 8) { @@ -1795,6 +1824,113 @@ struct ClipboardSettings: View { } } +struct ColorPickerSettings: View { + @ObservedObject var colorPickerManager = ColorPickerManager.shared + @Default(.enableColorPickerFeature) var enableColorPickerFeature + @Default(.showColorFormats) var showColorFormats + @Default(.colorPickerDisplayMode) var colorPickerDisplayMode + @Default(.colorHistorySize) var colorHistorySize + @Default(.showColorPickerIcon) var showColorPickerIcon + + var body: some View { + Form { + Section { + Defaults.Toggle("Enable Color Picker", key: .enableColorPickerFeature) + } header: { + Text("Color Picker") + } footer: { + Text("Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker.") + } + + if enableColorPickerFeature { + Section { + Defaults.Toggle("Show Color Picker Icon", key: .showColorPickerIcon) + + HStack { + Text("Display Mode") + Spacer() + Picker("Display Mode", selection: $colorPickerDisplayMode) { + ForEach(ColorPickerDisplayMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.menu) + .frame(width: 100) + } + + HStack { + Text("History Size") + Spacer() + Picker("History Size", selection: $colorHistorySize) { + Text("5 colors").tag(5) + Text("10 colors").tag(10) + Text("15 colors").tag(15) + Text("20 colors").tag(20) + } + .pickerStyle(.menu) + .frame(width: 100) + } + + Defaults.Toggle("Show All Color Formats", key: .showColorFormats) + + } header: { + Text("Settings") + } footer: { + switch colorPickerDisplayMode { + case .popover: + Text("Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window.") + case .panel: + Text("Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button.") + } + } + + Section { + HStack { + Text("Color History") + Spacer() + Text("\(colorPickerManager.colorHistory.count)") + .foregroundColor(.secondary) + } + + HStack { + Text("Picking Status") + Spacer() + Text(colorPickerManager.isPickingColor ? "Active" : "Ready") + .foregroundColor(colorPickerManager.isPickingColor ? .green : .secondary) + } + + Button("Show Color Picker Panel") { + ColorPickerPanelManager.shared.showColorPickerPanel() + } + .disabled(!enableColorPickerFeature) + + } header: { + Text("Status & Actions") + } + + Section { + Button("Clear Color History") { + colorPickerManager.clearHistory() + } + .foregroundColor(.red) + .disabled(colorPickerManager.colorHistory.isEmpty) + + Button("Start Color Picking") { + colorPickerManager.startColorPicking() + } + .disabled(!enableColorPickerFeature || colorPickerManager.isPickingColor) + + } header: { + Text("Quick Actions") + } footer: { + Text("Clear color history removes all picked colors. Start color picking begins screen color capture mode.") + } + } + } + .navigationTitle("Color Picker") + } +} + #Preview { HUD() } diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 7035c05..7bb3027 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -22,29 +22,28 @@ struct TabSelectionView: View { @Default(.enableColorPickerFeature) var enableColorPickerFeature @Namespace var animation - var availableTabs: [TabModel] { - var tabs = [ - TabModel(label: "Home", icon: "house.fill", view: .home), - TabModel(label: "Shelf", icon: "tray.fill", view: .shelf) - ] + private var tabs: [TabModel] { + var tabsArray: [TabModel] = [] - if enableTimerFeature { - tabs.append(TabModel(label: "Timer", icon: "timer", view: .timer)) + tabsArray.append(TabModel(label: "Home", icon: "house.fill", view: .home)) + + tabsArray.append(TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)) + + // Timer tab only shown when timer feature is enabled + if Defaults[.enableTimerFeature] { + tabsArray.append(TabModel(label: "Timer", icon: "timer", view: .timer)) } - if enableStatsFeature { - tabs.append(TabModel(label: "Stats", icon: "chart.line.uptrend.xyaxis", view: .stats)) + // Stats tab only shown when stats feature is enabled + if Defaults[.enableStatsFeature] { + tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats)) } - if enableColorPickerFeature { - tabs.append(TabModel(label: "ColorPicker", icon: "eyedropper", view: .colorPicker)) - } - - return tabs + return tabsArray } var body: some View { HStack(spacing: 0) { - ForEach(availableTabs) { tab in + ForEach(tabs) { tab in TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) { withAnimation(.smooth) { coordinator.currentView = tab.view diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 92dd270..7aa3525 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -49,6 +49,25 @@ enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable } } +enum ColorPickerDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { + case popover = "popover" // Traditional popover attached to button + case panel = "panel" // Floating panel near notch + + var displayName: String { + switch self { + case .popover: return "Popover" + case .panel: return "Panel" + } + } + + var description: String { + switch self { + case .popover: return "Shows color picker as a dropdown attached to the color picker button" + case .panel: return "Shows color picker in a floating panel near the notch" + } + } +} + enum HideNotchOption: String, Defaults.Serializable { case always case nowPlayingOnly @@ -189,6 +208,9 @@ extension Defaults.Keys { // MARK: ColorPicker Feature static let enableColorPickerFeature = Key("enableColorPickerFeature", default: true) static let showColorFormats = Key("showColorFormats", default: true) + static let colorPickerDisplayMode = Key("colorPickerDisplayMode", default: .panel) + static let colorHistorySize = Key("colorHistorySize", default: 10) + static let showColorPickerIcon = Key("showColorPickerIcon", default: true) // MARK: Clipboard Feature static let enableClipboardManager = Key("enableClipboardManager", default: true) From 4826cb5d44090b6c22a3d5a158671972d17fca33 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 24 Aug 2025 19:43:27 +0530 Subject: [PATCH 5/5] Fix: Closing when mouse hover out --- DynamicIsland/ContentView.swift | 14 ++++++++++--- .../Notch/DynamicIslandHeader.swift | 20 +++++++++++++++++++ .../models/DynamicIslandViewModel.swift | 3 +++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 9ed11da..d0639dd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -124,13 +124,13 @@ struct ContentView: View { } // Only close if mouse leaves and the notch is open and no popovers are active - if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive { + if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive { // Add slight delay for stats tab to prevent accidental closure during layout changes let delay: Double = (coordinator.currentView == .stats) ? 0.2 : 0.0 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { // Double-check conditions after delay - if !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive { + if !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive { vm.close() } } @@ -185,6 +185,14 @@ struct ContentView: View { } } } + .onChange(of: vm.shouldRecheckHover) { _, _ in + // Recheck hover state when popovers are closed + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive && !isHovering { + vm.close() + } + } + } .sensoryFeedback(.alignment, trigger: haptics) .contextMenu { SettingsLink(label: { @@ -527,7 +535,7 @@ struct ContentView: View { } // Close the notch if it's open and no popovers are active - if vm.notchState == .open && !vm.isBatteryPopoverActive { + if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive && !vm.isColorPickerPopoverActive { vm.close() } } diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 6f0a2ec..449d8b4 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -86,6 +86,16 @@ struct DynamicIslandHeader: View { .popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) { ClipboardPopover() } + .onChange(of: showClipboardPopover) { isActive in + vm.isClipboardPopoverActive = isActive + + // If popover was closed, trigger a hover recheck + if !isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + vm.shouldRecheckHover.toggle() + } + } + } .onAppear { if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring { clipboardManager.startMonitoring() @@ -117,6 +127,16 @@ struct DynamicIslandHeader: View { .popover(isPresented: $showColorPickerPopover, arrowEdge: .bottom) { ColorPickerPopover() } + .onChange(of: showColorPickerPopover) { isActive in + vm.isColorPickerPopoverActive = isActive + + // If popover was closed, trigger a hover recheck + if !isActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + vm.shouldRecheckHover.toggle() + } + } + } } if Defaults[.settingsIconInNotch] { diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index e29d114..85fe418 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -29,6 +29,9 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var hideOnClosed: Bool = true @Published var isHoveringCalendar: Bool = false @Published var isBatteryPopoverActive: Bool = false + @Published var isClipboardPopoverActive: Bool = false + @Published var isColorPickerPopoverActive: Bool = false + @Published var shouldRecheckHover: Bool = false let webcamManager = WebcamManager.shared @Published var isCameraExpanded: Bool = false