mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #20 from Ebullioscopic/feature/colorpicker
Feature/colorpicker
This commit is contained in:
commit
1a91eb75c7
19 changed files with 2024 additions and 24 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -62,4 +62,5 @@ integration.json
|
||||||
# GitHub instructions and agent files
|
# GitHub instructions and agent files
|
||||||
#
|
#
|
||||||
.github/
|
.github/
|
||||||
rough_note.md
|
rough_note.md
|
||||||
|
Solid
|
||||||
|
|
@ -124,13 +124,13 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only close if mouse leaves and the notch is open and no popovers are active
|
// 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
|
// Add slight delay for stats tab to prevent accidental closure during layout changes
|
||||||
let delay: Double = (coordinator.currentView == .stats) ? 0.2 : 0.0
|
let delay: Double = (coordinator.currentView == .stats) ? 0.2 : 0.0
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
// Double-check conditions after 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()
|
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)
|
.sensoryFeedback(.alignment, trigger: haptics)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
SettingsLink(label: {
|
SettingsLink(label: {
|
||||||
|
|
@ -322,6 +330,8 @@ struct ContentView: View {
|
||||||
NotchTimerView()
|
NotchTimerView()
|
||||||
case .stats:
|
case .stats:
|
||||||
NotchStatsView()
|
NotchStatsView()
|
||||||
|
case .colorPicker:
|
||||||
|
NotchColorPickerView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
|
|
@ -525,7 +535,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the notch if it's open and no popovers are active
|
// 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()
|
vm.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@
|
||||||
},
|
},
|
||||||
"%lld" : {
|
"%lld" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld%%" : {
|
"%lld%%" : {
|
||||||
|
|
||||||
|
|
@ -55,15 +58,27 @@
|
||||||
},
|
},
|
||||||
"3 items" : {
|
"3 items" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"5 colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"5 items" : {
|
"5 items" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"7 items" : {
|
"7 items" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"10 colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"10 items" : {
|
"10 items" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"15 colors" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"20 colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"About" : {
|
"About" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -139,6 +154,9 @@
|
||||||
},
|
},
|
||||||
"Appearance" : {
|
"Appearance" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Are you sure you want to delete this color from your history?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Automatically check for updates" : {
|
"Automatically check for updates" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -277,12 +295,21 @@
|
||||||
},
|
},
|
||||||
"Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." : {
|
"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" : {
|
"Clear Data" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clear Pinned Items" : {
|
"Clear Pinned Items" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Click 'Pick Color' or use Cmd+Shift+P to start picking colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clipboard" : {
|
"Clipboard" : {
|
||||||
|
|
||||||
|
|
@ -301,6 +328,30 @@
|
||||||
},
|
},
|
||||||
"Close" : {
|
"Close" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Color Details" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Color Formats" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Color History" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Color Picked!" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Color Picker" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Color Picker feature is disabled" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Color Picker Panel:" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"ColorPicker Disabled" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Coming soon" : {
|
"Coming soon" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -379,6 +430,12 @@
|
||||||
},
|
},
|
||||||
"Default: dynamic.m4a" : {
|
"Default: dynamic.m4a" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Delete" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Delete Color" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Description" : {
|
"Description" : {
|
||||||
|
|
||||||
|
|
@ -465,6 +522,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Enable ColorPicker in Settings → ColorPicker" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Enable global keyboard shortcuts above to customize your shortcuts." : {
|
"Enable global keyboard shortcuts above to customize your shortcuts." : {
|
||||||
|
|
||||||
|
|
@ -491,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 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" : {
|
"Enable sneak peek" : {
|
||||||
|
|
||||||
|
|
@ -781,6 +844,15 @@
|
||||||
},
|
},
|
||||||
"No clipboard history" : {
|
"No clipboard history" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No Colors Found" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No colors match '%@'" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No Colors Yet" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No custom animation available" : {
|
"No custom animation available" : {
|
||||||
|
|
||||||
|
|
@ -872,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 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." : {
|
"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 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" : {
|
"Pause" : {
|
||||||
|
|
||||||
|
|
@ -894,6 +972,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Pick Color" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Picked %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Picked Color" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Picking Status" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Pin items from history to save them here" : {
|
"Pin items from history to save them here" : {
|
||||||
|
|
||||||
|
|
@ -912,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 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" : {
|
"Progress bar" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -925,6 +1018,9 @@
|
||||||
},
|
},
|
||||||
"Progressbar style" : {
|
"Progressbar style" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Quick Actions" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Quit" : {
|
"Quit" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -949,6 +1045,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Ready" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Recent Colors" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Release name" : {
|
"Release name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -980,6 +1082,9 @@
|
||||||
},
|
},
|
||||||
"Search clipboard..." : {
|
"Search clipboard..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Search colors..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Search..." : {
|
"Search..." : {
|
||||||
|
|
||||||
|
|
@ -997,6 +1102,9 @@
|
||||||
},
|
},
|
||||||
"Seconds" : {
|
"Seconds" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Select a color to view details" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Select Calendars" : {
|
"Select Calendars" : {
|
||||||
|
|
||||||
|
|
@ -1050,6 +1158,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Show Color History" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Show Color Picker Panel" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Show cool face animation while inactivity" : {
|
"Show cool face animation while inactivity" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|
@ -1109,12 +1223,18 @@
|
||||||
},
|
},
|
||||||
"Start" : {
|
"Start" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Start Color Picking" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Start Demo Timer:" : {
|
"Start Demo Timer:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Start Monitoring" : {
|
"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." : {
|
"Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled." : {
|
||||||
|
|
||||||
|
|
@ -1139,6 +1259,9 @@
|
||||||
},
|
},
|
||||||
"Stats Panel:" : {
|
"Stats Panel:" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Status & Actions" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Stop" : {
|
"Stop" : {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import SwiftUI
|
||||||
|
|
||||||
extension KeyboardShortcuts.Name {
|
extension KeyboardShortcuts.Name {
|
||||||
static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command]))
|
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 statsPanel = Self("statsPanel", default: .init(.s, modifiers: [.shift, .command]))
|
||||||
static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function]))
|
static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function]))
|
||||||
static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command]))
|
static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command]))
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,9 @@ struct ClipboardPanelHeader: View {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
// Title and close button
|
// Title and close button
|
||||||
HStack {
|
HStack {
|
||||||
|
// Close button
|
||||||
|
NativeStyleCloseButton(action: onClose)
|
||||||
|
|
||||||
Image(systemName: "doc.on.clipboard")
|
Image(systemName: "doc.on.clipboard")
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
|
@ -234,13 +237,6 @@ struct ClipboardPanelHeader: View {
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
.disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty)
|
.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(.horizontal, 16)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
@ -329,6 +325,7 @@ struct ClipboardPanelItemRow: View {
|
||||||
let isPinned: Bool
|
let isPinned: Bool
|
||||||
let onHover: (UUID?) -> Void
|
let onHover: (UUID?) -> Void
|
||||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||||
|
@State private var justCopied = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
|
@ -381,10 +378,20 @@ struct ClipboardPanelItemRow: View {
|
||||||
// Copy button
|
// Copy button
|
||||||
Button(action: {
|
Button(action: {
|
||||||
clipboardManager.copyToClipboard(item)
|
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))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.green)
|
.foregroundColor(justCopied ? .green : .green)
|
||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
684
DynamicIsland/components/ColorPicker/ColorPickerPanel.swift
Normal file
684
DynamicIsland/components/ColorPicker/ColorPickerPanel.swift
Normal file
|
|
@ -0,0 +1,684 @@
|
||||||
|
//
|
||||||
|
// 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()
|
||||||
|
self.panel = newPanel
|
||||||
|
|
||||||
|
// Position the panel
|
||||||
|
newPanel.positionPanel()
|
||||||
|
|
||||||
|
// Make the panel visible and focused
|
||||||
|
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 { true }
|
||||||
|
|
||||||
|
init() {
|
||||||
|
super.init(
|
||||||
|
contentRect: .zero,
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: true
|
||||||
|
)
|
||||||
|
|
||||||
|
setupPanel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupPanel() {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostingView = NSHostingView(rootView: panelView)
|
||||||
|
contentView = hostingView
|
||||||
|
|
||||||
|
// Set initial size
|
||||||
|
let preferredSize = CGSize(width: 450, height: 600)
|
||||||
|
hostingView.setFrameSize(preferredSize)
|
||||||
|
setContentSize(preferredSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionPanel() {
|
||||||
|
guard let screen = NSScreen.main else { return }
|
||||||
|
|
||||||
|
let screenFrame = screen.visibleFrame
|
||||||
|
let panelFrame = frame
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Close button
|
||||||
|
NativeStyleCloseButton(action: onClose)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
.frame(width: 450, height: 600)
|
||||||
|
.onAppear {
|
||||||
|
ColorPickerManager.shared.colorHistory = PickedColor.sampleColors
|
||||||
|
}
|
||||||
|
}
|
||||||
130
DynamicIsland/components/ColorPicker/ColorPickerPopover.swift
Normal file
130
DynamicIsland/components/ColorPicker/ColorPickerPopover.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ struct DynamicIslandHeader: View {
|
||||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||||
@StateObject var tvm = TrayDrop.shared
|
@StateObject var tvm = TrayDrop.shared
|
||||||
@State private var showClipboardPopover = false
|
@State private var showClipboardPopover = false
|
||||||
|
@State private var showColorPickerPopover = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
|
@ -85,6 +86,16 @@ struct DynamicIslandHeader: View {
|
||||||
.popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) {
|
.popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) {
|
||||||
ClipboardPopover()
|
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 {
|
.onAppear {
|
||||||
if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring {
|
if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring {
|
||||||
clipboardManager.startMonitoring()
|
clipboardManager.startMonitoring()
|
||||||
|
|
@ -92,6 +103,42 @@ struct DynamicIslandHeader: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ColorPicker button
|
||||||
|
if Defaults[.enableColorPickerFeature] {
|
||||||
|
Button(action: {
|
||||||
|
switch Defaults[.colorPickerDisplayMode] {
|
||||||
|
case .panel:
|
||||||
|
ColorPickerPanelManager.shared.toggleColorPickerPanel()
|
||||||
|
case .popover:
|
||||||
|
showColorPickerPopover.toggle()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Capsule()
|
||||||
|
.fill(.black)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "eyedropper")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
|
.imageScale(.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.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] {
|
if Defaults[.settingsIconInNotch] {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
SettingsWindowController.shared.showWindow()
|
SettingsWindowController.shared.showWindow()
|
||||||
|
|
|
||||||
351
DynamicIsland/components/Notch/NotchColorPickerView.swift
Normal file
351
DynamicIsland/components/Notch/NotchColorPickerView.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,9 @@ struct SettingsView: View {
|
||||||
NavigationLink(value: "Clipboard") {
|
NavigationLink(value: "Clipboard") {
|
||||||
Label("Clipboard", systemImage: "clipboard")
|
Label("Clipboard", systemImage: "clipboard")
|
||||||
}
|
}
|
||||||
|
NavigationLink(value: "ColorPicker") {
|
||||||
|
Label("Color Picker", systemImage: "eyedropper")
|
||||||
|
}
|
||||||
if extensionManager.installedExtensions
|
if extensionManager.installedExtensions
|
||||||
.contains(where: { $0.bundleIdentifier == downloadManagerExtension }) {
|
.contains(where: { $0.bundleIdentifier == downloadManagerExtension }) {
|
||||||
NavigationLink(value: "Downloads") {
|
NavigationLink(value: "Downloads") {
|
||||||
|
|
@ -102,6 +105,8 @@ struct SettingsView: View {
|
||||||
StatsSettings()
|
StatsSettings()
|
||||||
case "Clipboard":
|
case "Clipboard":
|
||||||
ClipboardSettings()
|
ClipboardSettings()
|
||||||
|
case "ColorPicker":
|
||||||
|
ColorPickerSettings()
|
||||||
case "Downloads":
|
case "Downloads":
|
||||||
Downloads()
|
Downloads()
|
||||||
case "Shelf":
|
case "Shelf":
|
||||||
|
|
@ -1116,6 +1121,7 @@ struct Shortcuts: View {
|
||||||
@Default(.enableClipboardManager) var enableClipboardManager
|
@Default(.enableClipboardManager) var enableClipboardManager
|
||||||
@Default(.enableShortcuts) var enableShortcuts
|
@Default(.enableShortcuts) var enableShortcuts
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -1228,6 +1234,29 @@ struct Shortcuts: View {
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.font(.caption)
|
.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 {
|
} else {
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
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 {
|
#Preview {
|
||||||
HUD()
|
HUD()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,27 +19,31 @@ struct TabSelectionView: View {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@Default(.enableTimerFeature) var enableTimerFeature
|
@Default(.enableTimerFeature) var enableTimerFeature
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||||
@Namespace var animation
|
@Namespace var animation
|
||||||
|
|
||||||
var availableTabs: [TabModel] {
|
private var tabs: [TabModel] {
|
||||||
var tabs = [
|
var tabsArray: [TabModel] = []
|
||||||
TabModel(label: "Home", icon: "house.fill", view: .home),
|
|
||||||
TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)
|
|
||||||
]
|
|
||||||
|
|
||||||
if enableTimerFeature {
|
tabsArray.append(TabModel(label: "Home", icon: "house.fill", view: .home))
|
||||||
tabs.append(TabModel(label: "Timer", icon: "timer", view: .timer))
|
|
||||||
|
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 {
|
// Stats tab only shown when stats feature is enabled
|
||||||
tabs.append(TabModel(label: "Stats", icon: "chart.line.uptrend.xyaxis", view: .stats))
|
if Defaults[.enableStatsFeature] {
|
||||||
|
tabsArray.append(TabModel(label: "Stats", icon: "chart.xyaxis.line", view: .stats))
|
||||||
}
|
}
|
||||||
|
|
||||||
return tabs
|
return tabsArray
|
||||||
}
|
}
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(availableTabs) { tab in
|
ForEach(tabs) { tab in
|
||||||
TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) {
|
TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) {
|
||||||
withAnimation(.smooth) {
|
withAnimation(.smooth) {
|
||||||
coordinator.currentView = tab.view
|
coordinator.currentView = tab.view
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ public enum NotchViews {
|
||||||
case shelf
|
case shelf
|
||||||
case timer
|
case timer
|
||||||
case stats
|
case stats
|
||||||
|
case colorPicker
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SettingsEnum {
|
enum SettingsEnum {
|
||||||
|
|
|
||||||
199
DynamicIsland/managers/ColorPickerManager.swift
Normal file
199
DynamicIsland/managers/ColorPickerManager.swift
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
0
DynamicIsland/managers/ColorPickerManager_Fixed.swift
Normal file
0
DynamicIsland/managers/ColorPickerManager_Fixed.swift
Normal file
|
|
@ -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 {
|
enum HideNotchOption: String, Defaults.Serializable {
|
||||||
case always
|
case always
|
||||||
case nowPlayingOnly
|
case nowPlayingOnly
|
||||||
|
|
@ -186,6 +205,13 @@ extension Defaults.Keys {
|
||||||
// MARK: Timer Feature
|
// MARK: Timer Feature
|
||||||
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
static let enableTimerFeature = Key<Bool>("enableTimerFeature", default: true)
|
||||||
|
|
||||||
|
// MARK: ColorPicker Feature
|
||||||
|
static let enableColorPickerFeature = Key<Bool>("enableColorPickerFeature", default: true)
|
||||||
|
static let showColorFormats = Key<Bool>("showColorFormats", default: true)
|
||||||
|
static let colorPickerDisplayMode = Key<ColorPickerDisplayMode>("colorPickerDisplayMode", default: .panel)
|
||||||
|
static let colorHistorySize = Key<Int>("colorHistorySize", default: 10)
|
||||||
|
static let showColorPickerIcon = Key<Bool>("showColorPickerIcon", default: true)
|
||||||
|
|
||||||
// MARK: Clipboard Feature
|
// MARK: Clipboard Feature
|
||||||
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
|
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
|
||||||
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
|
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
||||||
@Published var hideOnClosed: Bool = true
|
@Published var hideOnClosed: Bool = true
|
||||||
@Published var isHoveringCalendar: Bool = false
|
@Published var isHoveringCalendar: Bool = false
|
||||||
@Published var isBatteryPopoverActive: 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
|
let webcamManager = WebcamManager.shared
|
||||||
@Published var isCameraExpanded: Bool = false
|
@Published var isCameraExpanded: Bool = false
|
||||||
|
|
|
||||||
198
DynamicIsland/models/PickedColor.swift
Normal file
198
DynamicIsland/models/PickedColor.swift
Normal file
|
|
@ -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))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue